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
@@ -164,12 +164,12 @@ module Carson
164
164
  end
165
165
 
166
166
  begin
167
- buf = verbose? ? out : StringIO.new
168
- err_buf = verbose? ? err : StringIO.new
169
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
170
- status = rt.prune!
167
+ buffer = verbose? ? output : StringIO.new
168
+ error_buffer = verbose? ? error : StringIO.new
169
+ scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
170
+ status = scoped_runtime.prune!
171
171
  unless verbose?
172
- summary = buf.string.lines.last.to_s.strip
172
+ summary = buffer.string.lines.last.to_s.strip
173
173
  puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
174
174
  end
175
175
  if status == EXIT_ERROR
@@ -179,9 +179,9 @@ module Carson
179
179
  clear_batch_success( command: "prune", repo_path: repo_path )
180
180
  succeeded += 1
181
181
  end
182
- rescue StandardError => e
183
- puts_line "#{repo_name}: FAIL (#{e.message})"
184
- record_batch_skip( command: "prune", repo_path: repo_path, reason: e.message )
182
+ rescue StandardError => exception
183
+ puts_line "#{repo_name}: FAIL (#{exception.message})"
184
+ record_batch_skip( command: "prune", repo_path: repo_path, reason: exception.message )
185
185
  failed += 1
186
186
  end
187
187
  end
@@ -294,7 +294,7 @@ module Carson
294
294
  def onboard_run_audit!
295
295
  audit_error = nil
296
296
  audit_status = with_captured_output { audit! }
297
- rescue StandardError => e
297
+ rescue StandardError => exception
298
298
  audit_error = e
299
299
  audit_status = EXIT_OK
300
300
  ensure
@@ -339,17 +339,17 @@ module Carson
339
339
  # Refreshes a single governed repository using a scoped Runtime.
340
340
  def refresh_single_repo( repo_path:, repo_name: )
341
341
  if verbose?
342
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
342
+ scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: true )
343
343
  else
344
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
344
+ scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: StringIO.new, error: StringIO.new )
345
345
  end
346
- status = rt.refresh!
346
+ status = scoped_runtime.refresh!
347
347
  label = refresh_status_label( status: status )
348
- sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
348
+ sync_suffix = refresh_sync_suffix( result: scoped_runtime.template_sync_result )
349
349
  puts_line "#{repo_name}: #{label}#{sync_suffix}"
350
350
  status
351
- rescue StandardError => e
352
- puts_line "#{repo_name}: FAIL (#{e.message})"
351
+ rescue StandardError => exception
352
+ puts_line "#{repo_name}: FAIL (#{exception.message})"
353
353
  EXIT_ERROR
354
354
  end
355
355
 
@@ -376,8 +376,8 @@ module Carson
376
376
  git_system!( "config", "--unset", "core.hooksPath" )
377
377
  puts_verbose "hooks_path_unset: core.hooksPath"
378
378
  EXIT_OK
379
- rescue StandardError => e
380
- puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
379
+ rescue StandardError => exception
380
+ puts_line "ERROR: unable to update core.hooksPath (#{exception.message})"
381
381
  EXIT_ERROR
382
382
  end
383
383
 
@@ -8,7 +8,7 @@ module Carson
8
8
  fingerprint_status = block_if_outsider_fingerprints!
9
9
  unless fingerprint_status.nil?
10
10
  if json_output
11
- out.puts JSON.pretty_generate( {
11
+ output.puts JSON.pretty_generate( {
12
12
  command: "prune", status: "block",
13
13
  error: "Carson-owned artefacts detected in host repository",
14
14
  recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
@@ -51,7 +51,7 @@ module Carson
51
51
  result[ :exit_code ] = exit_code
52
52
 
53
53
  if json_output
54
- out.puts JSON.pretty_generate( result )
54
+ output.puts JSON.pretty_generate( result )
55
55
  else
56
56
  print_prune_human( counters: counters )
57
57
  end
@@ -116,7 +116,7 @@ module Carson
116
116
  end
117
117
 
118
118
  def prune_skip_stale_branch( type:, branch:, upstream: )
119
- reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked out in CWD worktree" }.fetch( type, type.to_s )
119
+ reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked output in CWD worktree" }.fetch( type, type.to_s )
120
120
  status = { protected: "skip_protected_branch", current: "skip_current_branch", cwd_worktree: "skip_cwd_worktree_branch" }.fetch( type, "skip_#{type}" )
121
121
  puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
122
122
  { action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: reason }
@@ -135,7 +135,7 @@ module Carson
135
135
  end
136
136
 
137
137
  def prune_safe_delete_success( branch:, upstream:, stdout_text: )
138
- out.print stdout_text if verbose? && !stdout_text.empty?
138
+ output.print stdout_text if verbose? && !stdout_text.empty?
139
139
  puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
140
140
  { action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "upstream gone" }
141
141
  end
@@ -154,7 +154,7 @@ module Carson
154
154
  end
155
155
 
156
156
  def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
157
- out.print force_stdout if verbose? && !force_stdout.empty?
157
+ output.print force_stdout if verbose? && !force_stdout.empty?
158
158
  puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
159
159
  { action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "force deleted with PR evidence" }
160
160
  end
@@ -195,9 +195,9 @@ module Carson
195
195
  error_text.to_s.downcase.include?( "used by worktree" )
196
196
  end
197
197
 
198
- # Returns the worktree path for a branch, or nil if not checked out in any worktree.
198
+ # Returns the worktree path for a branch, or nil if not checked output in any worktree.
199
199
  def worktree_path_for_branch( branch: )
200
- entry = worktree_list.find { |wt| wt.fetch( :branch, nil ) == branch }
200
+ entry = worktree_list.find { |worktree| worktree.fetch( :branch, nil ) == branch }
201
201
  entry&.fetch( :path, nil )
202
202
  end
203
203
 
@@ -307,7 +307,7 @@ module Carson
307
307
  return { action: :skipped, branch: branch, upstream: upstream, type: "absorbed", reason: error_text }
308
308
  end
309
309
 
310
- out.print force_stdout if verbose? && !force_stdout.empty?
310
+ output.print force_stdout if verbose? && !force_stdout.empty?
311
311
 
312
312
  remote_branch = upstream.sub( "#{config.git_remote}/", "" )
313
313
  git_run( "push", config.git_remote, "--delete", remote_branch )
@@ -372,7 +372,7 @@ module Carson
372
372
 
373
373
  force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
374
374
  if force_success
375
- out.print force_stdout if verbose? && !force_stdout.empty?
375
+ output.print force_stdout if verbose? && !force_stdout.empty?
376
376
  puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
377
377
  return { action: :deleted, branch: branch, upstream: "", type: "orphan", reason: "merged PR evidence found" }
378
378
  end
@@ -486,10 +486,10 @@ module Carson
486
486
  return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
487
487
 
488
488
  [ latest, nil ]
489
- rescue JSON::ParserError => e
490
- [ nil, "invalid gh JSON response (#{e.message})" ]
491
- rescue StandardError => e
492
- [ nil, e.message ]
489
+ rescue JSON::ParserError => exception
490
+ [ nil, "invalid gh JSON response (#{exception.message})" ]
491
+ rescue StandardError => exception
492
+ [ nil, exception.message ]
493
493
  end
494
494
  end
495
495
  end
@@ -66,8 +66,8 @@ module Carson
66
66
  end
67
67
 
68
68
  begin
69
- rt = build_scoped_runtime( repo_path: repo_path )
70
- status = rt.sync!
69
+ scoped_runtime = build_scoped_runtime( repo_path: repo_path )
70
+ status = scoped_runtime.sync!
71
71
  if status == EXIT_OK
72
72
  puts_line "#{repo_name}: ok" unless verbose?
73
73
  clear_batch_success( command: "sync", repo_path: repo_path )
@@ -77,9 +77,9 @@ module Carson
77
77
  record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
78
78
  failed += 1
79
79
  end
80
- rescue StandardError => e
81
- puts_line "#{repo_name}: FAIL (#{e.message})"
82
- record_batch_skip( command: "sync", repo_path: repo_path, reason: e.message )
80
+ rescue StandardError => exception
81
+ puts_line "#{repo_name}: FAIL (#{exception.message})"
82
+ record_batch_skip( command: "sync", repo_path: repo_path, reason: exception.message )
83
83
  failed += 1
84
84
  end
85
85
  end
@@ -106,7 +106,7 @@ module Carson
106
106
  result[ :exit_code ] = exit_code
107
107
 
108
108
  if json_output
109
- out.puts JSON.pretty_generate( result )
109
+ output.puts JSON.pretty_generate( result )
110
110
  else
111
111
  print_sync_human( result: result )
112
112
  end
@@ -1,3 +1,4 @@
1
+ # Detects template drift, applies canonical files, and propagates changes via PR.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Local
@@ -9,7 +10,7 @@ module Carson
9
10
  ".github/.mega-linter.yml"
10
11
  ].freeze
11
12
 
12
- # Read-only template drift check; returns block when managed files are out of sync.
13
+ # Read-only template drift check; returns block when managed files are output of sync.
13
14
  def template_check!
14
15
  fingerprint_status = block_if_outsider_fingerprints!
15
16
  return fingerprint_status unless fingerprint_status.nil?
@@ -68,8 +69,8 @@ module Carson
68
69
  end
69
70
 
70
71
  begin
71
- rt = build_scoped_runtime( repo_path: repo_path )
72
- status = rt.template_check!
72
+ scoped_runtime = build_scoped_runtime( repo_path: repo_path )
73
+ status = scoped_runtime.template_check!
73
74
  if status == EXIT_OK
74
75
  puts_line "#{repo_name}: in sync" unless verbose?
75
76
  clear_batch_success( command: "template_check", repo_path: repo_path )
@@ -78,9 +79,9 @@ module Carson
78
79
  puts_line "#{repo_name}: DRIFT" unless verbose?
79
80
  drifted += 1
80
81
  end
81
- rescue StandardError => e
82
- puts_line "#{repo_name}: FAIL (#{e.message})"
83
- record_batch_skip( command: "template_check", repo_path: repo_path, reason: e.message )
82
+ rescue StandardError => exception
83
+ puts_line "#{repo_name}: FAIL (#{exception.message})"
84
+ record_batch_skip( command: "template_check", repo_path: repo_path, reason: exception.message )
84
85
  failed += 1
85
86
  end
86
87
  end
@@ -171,9 +172,9 @@ module Carson
171
172
  result = template_propagate_deliver!( worktree_dir: worktree_dir )
172
173
  template_propagate_report!( result: result )
173
174
  result
174
- rescue StandardError => e
175
- puts_verbose "template_propagate: error (#{e.message})"
176
- { status: :error, reason: e.message }
175
+ rescue StandardError => exception
176
+ puts_verbose "template_propagate: error (#{exception.message})"
177
+ { status: :error, reason: exception.message }
177
178
  ensure
178
179
  template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
179
180
  end
@@ -181,12 +182,12 @@ module Carson
181
182
 
182
183
  def template_propagate_create_worktree!
183
184
  worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
184
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
185
+ worktree_git = Adapters::Git.new( repo_root: worktree_dir )
185
186
 
186
187
  git_system!( "fetch", config.git_remote, config.main_branch )
187
188
  git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
188
- wt_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
189
- wt_git.run( "config", "core.hooksPath", "/dev/null" )
189
+ worktree_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
190
+ worktree_git.run( "config", "core.hooksPath", "/dev/null" )
190
191
  puts_verbose "template_propagate: worktree created at #{worktree_dir}"
191
192
  worktree_dir
192
193
  end
@@ -211,13 +212,13 @@ module Carson
211
212
  end
212
213
 
213
214
  def template_propagate_commit!( worktree_dir: )
214
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
215
- wt_git.run( "add", "--all" )
215
+ worktree_git = Adapters::Git.new( repo_root: worktree_dir )
216
+ worktree_git.run( "add", "--all" )
216
217
 
217
- _, _, no_diff, = wt_git.run( "diff", "--cached", "--quiet" )
218
+ _, _, no_diff, = worktree_git.run( "diff", "--cached", "--quiet" )
218
219
  return false if no_diff
219
220
 
220
- wt_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
221
+ worktree_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
221
222
  puts_verbose "template_propagate: committed"
222
223
  true
223
224
  end
@@ -231,8 +232,8 @@ module Carson
231
232
  end
232
233
 
233
234
  def template_propagate_deliver_trunk!( worktree_dir: )
234
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
235
- stdout_text, stderr_text, success, = wt_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
235
+ worktree_git = Adapters::Git.new( repo_root: worktree_dir )
236
+ stdout_text, stderr_text, success, = worktree_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
236
237
  unless success
237
238
  error_text = stderr_text.to_s.strip
238
239
  error_text = "push to #{config.main_branch} failed" if error_text.empty?
@@ -243,8 +244,8 @@ module Carson
243
244
  end
244
245
 
245
246
  def template_propagate_deliver_branch!( worktree_dir: )
246
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
247
- stdout_text, stderr_text, success, = wt_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
247
+ worktree_git = Adapters::Git.new( repo_root: worktree_dir )
248
+ stdout_text, stderr_text, success, = worktree_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
248
249
  unless success
249
250
  error_text = stderr_text.to_s.strip
250
251
  error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
@@ -296,8 +297,8 @@ module Carson
296
297
  git_run( "worktree", "remove", "--force", worktree_dir ) unless safe_success
297
298
  git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
298
299
  puts_verbose "template_propagate: worktree and local branch cleaned up"
299
- rescue StandardError => e
300
- puts_verbose "template_propagate: cleanup warning (#{e.message})"
300
+ rescue StandardError => exception
301
+ puts_verbose "template_propagate: cleanup warning (#{exception.message})"
301
302
  end
302
303
 
303
304
  def template_propagate_report!( result: )
@@ -381,14 +382,14 @@ module Carson
381
382
  def managed_dirty_paths
382
383
  template_paths = config.template_managed_files + SUPERSEDED
383
384
  linters_glob = Dir.glob( File.join( repo_root, ".github/linters/**/*" ) )
384
- .select { |p| File.file?( p ) }
385
- .map { |p| p.delete_prefix( "#{repo_root}/" ) }
385
+ .select { |path| File.file?( path ) }
386
+ .map { |path| path.delete_prefix( "#{repo_root}/" ) }
386
387
  candidates = ( template_paths + linters_glob ).uniq
387
388
  return [] if candidates.empty?
388
389
 
389
390
  stdout_text, = git_capture_soft( "status", "--porcelain", "--", *candidates )
390
391
  stdout_text.to_s.lines
391
- .map { |l| l[ 3.. ].strip }
392
+ .map { |line| line[ 3.. ].strip }
392
393
  .reject( &:empty? )
393
394
  end
394
395
  end
@@ -1,7 +1,8 @@
1
1
  # Safe worktree lifecycle management for coding agents.
2
2
  # Two operations: create and remove. Create auto-syncs main before branching.
3
- # Remove is safe by default — guards against CWD-inside-worktree and unpushed
4
- # commits. Content-aware: allows removal after squash/rebase merge without --force.
3
+ # Remove is safe by default — guards against CWD-inside-worktree, cross-process
4
+ # CWD holds, and unpushed commits. Content-aware: allows removal after
5
+ # squash/rebase merge without --force.
5
6
  # Supports --json for machine-readable structured output with recovery commands.
6
7
  module Carson
7
8
  class Runtime
@@ -14,11 +15,11 @@ module Carson
14
15
  # Uses main_worktree_root so this works even when called from inside a worktree.
15
16
  def worktree_create!( name:, json_output: false )
16
17
  worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
17
- wt_path = File.join( worktrees_dir, name )
18
+ worktree_path = File.join( worktrees_dir, name )
18
19
 
19
- if Dir.exist?( wt_path )
20
+ if Dir.exist?( worktree_path )
20
21
  return worktree_finish(
21
- result: { command: "worktree create", status: "error", name: name, path: wt_path,
22
+ result: { command: "worktree create", status: "error", name: name, path: worktree_path,
22
23
  error: "worktree already exists: #{name}",
23
24
  recovery: "carson worktree remove #{name}, then retry" },
24
25
  exit_code: EXIT_ERROR, json_output: json_output
@@ -41,9 +42,9 @@ module Carson
41
42
 
42
43
  # Create the worktree with a new branch based on the main branch.
43
44
  FileUtils.mkdir_p( worktrees_dir )
44
- _, wt_stderr, wt_success, = git_run( "worktree", "add", wt_path, "-b", name, base )
45
- unless wt_success
46
- error_text = wt_stderr.to_s.strip
45
+ _, worktree_stderr, worktree_success, = git_run( "worktree", "add", worktree_path, "-b", name, base )
46
+ unless worktree_success
47
+ error_text = worktree_stderr.to_s.strip
47
48
  error_text = "unable to create worktree" if error_text.empty?
48
49
  return worktree_finish(
49
50
  result: { command: "worktree create", status: "error", name: name,
@@ -53,7 +54,7 @@ module Carson
53
54
  end
54
55
 
55
56
  worktree_finish(
56
- result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
57
+ result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
57
58
  exit_code: EXIT_OK, json_output: json_output
58
59
  )
59
60
  end
@@ -65,7 +66,7 @@ module Carson
65
66
  fingerprint_status = block_if_outsider_fingerprints!
66
67
  unless fingerprint_status.nil?
67
68
  if json_output
68
- out.puts JSON.pretty_generate( {
69
+ output.puts JSON.pretty_generate( {
69
70
  command: "worktree remove", status: "block",
70
71
  error: "Carson-owned artefacts detected in host repository",
71
72
  recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
@@ -78,12 +79,12 @@ module Carson
78
79
  resolved_path = resolve_worktree_path( worktree_path: worktree_path )
79
80
 
80
81
  # Missing directory: worktree was destroyed externally (e.g. gh pr merge
81
- # --delete-branch). Clean up the stale git registration and delete the branch.
82
- if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
83
- return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
84
- end
82
+ # --delete-branch). Clean up the stale git registration and delete the branch.
83
+ if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
84
+ return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
85
+ end
85
86
 
86
- unless worktree_registered?( path: resolved_path )
87
+ unless worktree_registered?( path: resolved_path )
87
88
  return worktree_finish(
88
89
  result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
89
90
  error: "#{resolved_path} is not a registered worktree",
@@ -104,6 +105,18 @@ module Carson
104
105
  )
105
106
  end
106
107
 
108
+ # Safety: refuse if another process has its CWD inside the worktree.
109
+ # Protects against cross-process CWD crashes (e.g. an agent session
110
+ # removed by a separate cleanup process while the agent's shell is inside).
111
+ if worktree_held_by_other_process?( worktree_path: resolved_path )
112
+ return worktree_finish(
113
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
114
+ error: "another process has its working directory inside this worktree",
115
+ recovery: "wait for the other session to finish, then retry" },
116
+ exit_code: EXIT_BLOCK, json_output: json_output
117
+ )
118
+ end
119
+
107
120
  branch = worktree_branch( path: resolved_path )
108
121
  puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
109
122
 
@@ -189,12 +202,13 @@ module Carson
189
202
  end
190
203
  return if agent_prefixes.empty?
191
204
 
192
- worktrees.each do |wt|
193
- path = wt.fetch( :path )
194
- branch = wt.fetch( :branch, nil )
205
+ worktrees.each do |worktree|
206
+ path = worktree.fetch( :path )
207
+ branch = worktree.fetch( :branch, nil )
195
208
  next unless branch
196
209
  next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
197
210
  next if cwd_inside_worktree?( worktree_path: path )
211
+ next if worktree_held_by_other_process?( worktree_path: path )
198
212
  next unless branch_absorbed_into_main?( branch: branch )
199
213
 
200
214
  # Remove the worktree (no --force: refuses if dirty working tree).
@@ -256,7 +270,7 @@ module Carson
256
270
  result[ :exit_code ] = exit_code
257
271
 
258
272
  if json_output
259
- out.puts JSON.pretty_generate( result )
273
+ output.puts JSON.pretty_generate( result )
260
274
  else
261
275
  print_worktree_human( result: result )
262
276
  end
@@ -296,9 +310,38 @@ module Carson
296
310
  # Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
297
311
  def cwd_inside_worktree?( worktree_path: )
298
312
  cwd = realpath_safe( Dir.pwd )
299
- wt = realpath_safe( worktree_path )
300
- normalised_wt = File.join( wt, "" )
301
- cwd == wt || cwd.start_with?( normalised_wt )
313
+ worktree = realpath_safe( worktree_path )
314
+ normalised_wt = File.join( worktree, "" )
315
+ cwd == worktree || cwd.start_with?( normalised_wt )
316
+ rescue StandardError
317
+ false
318
+ end
319
+
320
+ # Checks whether any other process has its working directory inside the worktree.
321
+ # Uses lsof to query CWD file descriptors system-wide, then matches against
322
+ # the worktree path. Catches the cross-process CWD crash scenario: a cleanup
323
+ # process removing a worktree while another session's shell is still inside it.
324
+ # Fails safe: returns false if lsof is unavailable or any error occurs.
325
+ def worktree_held_by_other_process?( worktree_path: )
326
+ canonical = realpath_safe( worktree_path )
327
+ return false if canonical.nil? || canonical.empty?
328
+ return false unless Dir.exist?( canonical )
329
+
330
+ stdout, _, status = Open3.capture3( "lsof", "-d", "cwd" )
331
+ return false unless status.success?
332
+
333
+ normalised = File.join( canonical, "" )
334
+ my_pid = Process.pid
335
+ stdout.lines.drop( 1 ).any? do |line|
336
+ fields = line.strip.split( /\s+/ )
337
+ next false unless fields.length >= 9
338
+ next false if fields[ 1 ].to_i == my_pid
339
+ name = fields[ 8.. ].join( " " )
340
+ name == canonical || name.start_with?( normalised )
341
+ end
342
+ rescue Errno::ENOENT
343
+ # lsof not installed.
344
+ false
302
345
  rescue StandardError
303
346
  false
304
347
  end
@@ -335,7 +378,7 @@ module Carson
335
378
  nil
336
379
  end
337
380
 
338
- # Returns the branch checked out in the worktree that contains the process CWD,
381
+ # Returns the branch checked output in the worktree that contains the process CWD,
339
382
  # or nil if CWD is not inside any worktree. Used by prune to proactively
340
383
  # protect the CWD worktree's branch from deletion.
341
384
  # Matches the longest (most specific) path because worktree directories
@@ -344,12 +387,12 @@ module Carson
344
387
  cwd = realpath_safe( Dir.pwd )
345
388
  best_branch = nil
346
389
  best_length = -1
347
- worktree_list.each do |wt|
348
- wt_path = wt.fetch( :path )
349
- normalised = File.join( wt_path, "" )
350
- if ( cwd == wt_path || cwd.start_with?( normalised ) ) && wt_path.length > best_length
351
- best_branch = wt.fetch( :branch, nil )
352
- best_length = wt_path.length
390
+ worktree_list.each do |worktree|
391
+ worktree_path = worktree.fetch( :path )
392
+ normalised = File.join( worktree_path, "" )
393
+ if ( cwd == worktree_path || cwd.start_with?( normalised ) ) && worktree_path.length > best_length
394
+ best_branch = worktree.fetch( :branch, nil )
395
+ best_length = worktree_path.length
353
396
  end
354
397
  end
355
398
  best_branch
@@ -383,7 +426,7 @@ module Carson
383
426
  existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
384
427
  return if existing.lines.any? { |line| line.strip == ".claude/" }
385
428
 
386
- File.open( exclude_path, "a" ) { |f| f.puts ".claude/" }
429
+ File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
387
430
  rescue StandardError
388
431
  # Best-effort — do not block worktree creation if exclude fails.
389
432
  end
@@ -408,14 +451,14 @@ module Carson
408
451
  # Compares using realpath to handle symlink differences.
409
452
  def worktree_registered?( path: )
410
453
  canonical = realpath_safe( path )
411
- worktree_list.any? { |wt| wt.fetch( :path ) == canonical }
454
+ worktree_list.any? { |worktree| worktree.fetch( :path ) == canonical }
412
455
  end
413
456
 
414
- # Returns the branch name checked out in a worktree, or nil.
457
+ # Returns the branch name checked output in a worktree, or nil.
415
458
  # Compares using realpath to handle symlink differences.
416
459
  def worktree_branch( path: )
417
460
  canonical = realpath_safe( path )
418
- entry = worktree_list.find { |wt| wt.fetch( :path ) == canonical }
461
+ entry = worktree_list.find { |worktree| worktree.fetch( :path ) == canonical }
419
462
  entry&.fetch( :branch, nil )
420
463
  end
421
464
 
@@ -1,3 +1,4 @@
1
+ # Aggregates local repository operation modules (sync, prune, hooks, worktree, template).
1
2
  require_relative "local/sync"
2
3
  require_relative "local/prune"
3
4
  require_relative "local/template"
@@ -9,7 +9,7 @@ module Carson
9
9
  repos = config.govern_repos
10
10
 
11
11
  if json_output
12
- out.puts JSON.pretty_generate( { command: "repos", repos: repos } )
12
+ output.puts JSON.pretty_generate( { command: "repos", repos: repos } )
13
13
  else
14
14
  if repos.empty?
15
15
  puts_line "No governed repositories."
@@ -1,3 +1,4 @@
1
+ # GraphQL data access: PR details, pagination, and normalisation for review gate and sweep.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Review
@@ -1,3 +1,4 @@
1
+ # Review gate logic: snapshot convergence, disposition acknowledgements, and merge-readiness checks.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Review
@@ -30,7 +31,7 @@ module Carson
30
31
  unresolved_threads = unresolved_thread_entries( details: details )
31
32
  actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
32
33
  acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
33
- unacknowledged_actionable = actionable_top_level.reject { |item| acknowledged_by_disposition?( item: item, acknowledgements: acknowledgements ) }
34
+ unacknowledged_actionable = actionable_top_level.reject { acknowledged_by_disposition?( item: it, acknowledgements: acknowledgements ) }
34
35
  {
35
36
  latest_activity: latest_review_activity( details: details ),
36
37
  unresolved_threads: unresolved_threads,
@@ -44,8 +45,8 @@ module Carson
44
45
  def review_gate_signature( snapshot: )
45
46
  {
46
47
  latest_activity: snapshot.fetch( :latest_activity ).to_s,
47
- unresolved_urls: snapshot.fetch( :unresolved_threads ).map { |entry| entry.fetch( :url ) }.sort,
48
- unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { |entry| entry.fetch( :url ) }.sort
48
+ unresolved_urls: snapshot.fetch( :unresolved_threads ).map { it.fetch( :url ) }.sort,
49
+ unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { it.fetch( :url ) }.sort
49
50
  }
50
51
  end
51
52
 
@@ -67,7 +68,7 @@ module Carson
67
68
  end
68
69
 
69
70
  def bot_username?( author: )
70
- config.review_bot_usernames.any? { |bot| bot.downcase == author.to_s.downcase }
71
+ config.review_bot_usernames.any? { it.downcase == author.to_s.downcase }
71
72
  end
72
73
 
73
74
  def unresolved_thread_entries( details: )
@@ -78,7 +79,7 @@ module Carson
78
79
  comments = thread.fetch( :comments )
79
80
  first_comment = comments.first || {}
80
81
  next if bot_username?( author: first_comment.fetch( :author, "" ) )
81
- latest_time = comments.map { |entry| entry.fetch( :created_at ) }.max.to_s
82
+ latest_time = comments.map { it.fetch( :created_at ) }.max.to_s
82
83
  {
83
84
  url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
84
85
  author: first_comment.fetch( :author, "" ),
@@ -130,7 +131,7 @@ module Carson
130
131
  sources = []
131
132
  sources.concat( Array( details.fetch( :comments ) ) )
132
133
  sources.concat( Array( details.fetch( :reviews ) ) )
133
- sources.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) } )
134
+ sources.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) } )
134
135
  sources.map do |entry|
135
136
  next unless entry.fetch( :author, "" ) == pr_author
136
137
  body = entry.fetch( :body, "" ).to_s
@@ -151,7 +152,7 @@ module Carson
151
152
  # True when any disposition acknowledgement references the specific finding URL.
152
153
  def acknowledged_by_disposition?( item:, acknowledgements: )
153
154
  acknowledgements.any? do |ack|
154
- Array( ack.fetch( :target_urls ) ).any? { |url| url == item.fetch( :url ) }
155
+ Array( ack.fetch( :target_urls ) ).any? { it == item.fetch( :url ) }
155
156
  end
156
157
  end
157
158
 
@@ -159,10 +160,10 @@ module Carson
159
160
  def latest_review_activity( details: )
160
161
  timestamps = []
161
162
  timestamps << details.fetch( :updated_at )
162
- timestamps.concat( Array( details.fetch( :comments ) ).map { |entry| entry.fetch( :created_at ) } )
163
- timestamps.concat( Array( details.fetch( :reviews ) ).map { |entry| entry.fetch( :created_at ) } )
164
- timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.map { |entry| entry.fetch( :created_at ) } )
165
- timestamps.map { |text| parse_time_or_nil( text: text ) }.compact.max&.utc&.iso8601
163
+ timestamps.concat( Array( details.fetch( :comments ) ).map { it.fetch( :created_at ) } )
164
+ timestamps.concat( Array( details.fetch( :reviews ) ).map { it.fetch( :created_at ) } )
165
+ timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.map { it.fetch( :created_at ) } )
166
+ timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
166
167
  end
167
168
 
168
169
  # Writes review gate artefacts using fixed report names in global report output.
@@ -175,8 +176,8 @@ module Carson
175
176
  )
176
177
  puts_verbose "review_gate_report_markdown: #{markdown_path}"
177
178
  puts_verbose "review_gate_report_json: #{json_path}"
178
- rescue StandardError => e
179
- puts_verbose "review_gate_report_write: SKIP (#{e.message})"
179
+ rescue StandardError => exception
180
+ puts_verbose "review_gate_report_write: SKIP (#{exception.message})"
180
181
  end
181
182
 
182
183
  # Human-readable review gate report for merge-readiness evidence.
@@ -208,7 +209,7 @@ module Carson
208
209
  if report.fetch( :block_reasons ).empty?
209
210
  lines << "- none"
210
211
  else
211
- report.fetch( :block_reasons ).each { |reason| lines << "- #{reason}" }
212
+ report.fetch( :block_reasons ).each { lines << "- #{it}" }
212
213
  end
213
214
  lines << ""
214
215
  lines << "## Unresolved Threads"