carson 3.22.1 → 3.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,8 @@
1
- # Agent briefing one command to know the full state of the estate.
2
- # Gathers branch, worktrees, open PRs, stale branches,
3
- # governance health, and version. Supports human-readable and JSON output.
1
+ # Agent-readable repository status centred on branch deliveries.
4
2
  module Carson
5
3
  class Runtime
6
4
  module Status
7
- # Entry point for `carson status`. Collects estate state and reports.
5
+ # Entry point for `carson status`.
8
6
  def status!( json_output: false )
9
7
  data = gather_status
10
8
 
@@ -19,115 +17,78 @@ module Carson
19
17
 
20
18
  # Portfolio-wide status overview across all governed repositories.
21
19
  def status_all!( json_output: false )
22
- repos = config.govern_repos
23
- if repos.empty?
20
+ repositories = config.govern_repos
21
+ if repositories.empty?
24
22
  puts_line "No governed repositories configured."
25
23
  puts_line " Run carson onboard in each repo to register."
26
24
  return EXIT_ERROR
27
25
  end
28
26
 
29
- if json_output
30
- results = []
31
- repos.each do |repo_path|
32
- repo_name = File.basename( repo_path )
33
- unless Dir.exist?( repo_path )
34
- results << { name: repo_name, status: "error", error: "path not found" }
35
- next
36
- end
27
+ results = repositories.map do |repo_path|
28
+ repo_name = File.basename( repo_path )
29
+ unless Dir.exist?( repo_path )
30
+ { name: repo_name, status: "error", error: "not found" }
31
+ else
37
32
  begin
38
33
  scoped_runtime = build_scoped_runtime( repo_path: repo_path )
39
- data = scoped_runtime.send( :gather_status )
40
- results << { name: repo_name, status: "ok" }.merge( data )
34
+ { name: repo_name, status: "ok" }.merge( scoped_runtime.send( :gather_status ) )
41
35
  rescue StandardError => exception
42
- results << { name: repo_name, status: "error", error: exception.message }
36
+ { name: repo_name, status: "error", error: exception.message }
43
37
  end
44
38
  end
45
- output.puts JSON.pretty_generate( { command: "status", repos: results } )
46
- return EXIT_OK
47
39
  end
48
40
 
49
- puts_line "Carson #{Carson::VERSION} — Portfolio (#{repos.length} repo#{plural_suffix( count: repos.length )})"
50
- puts_line ""
51
-
52
- all_pending = load_batch_pending
53
- repos.each do |repo_path|
54
- repo_name = File.basename( repo_path )
55
- unless Dir.exist?( repo_path )
56
- puts_line "#{repo_name}: not found"
57
- next
58
- end
59
-
60
- begin
61
- scoped_runtime = build_scoped_runtime( repo_path: repo_path )
62
- data = scoped_runtime.send( :gather_status )
63
- branch = data.fetch( :branch )
64
- dirty = format_dirty_marker( branch: branch )
65
- worktrees = data.fetch( :worktrees )
66
- gov = data.fetch( :governance )
67
- parts = []
68
- parts << branch.fetch( :name ) + dirty
69
- parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
70
- parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
71
- puts_line "#{repo_name}: #{parts.join( ' ' )}"
72
-
73
- # Show pending operations for this repo.
74
- repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
75
- repo_pending.each { |description| puts_line " pending: #{description}" }
76
- rescue StandardError => exception
77
- puts_line "#{repo_name}: could not read (#{exception.message})"
78
- end
41
+ if json_output
42
+ output.puts JSON.pretty_generate( { command: "status", repos: results, repositories: results } )
43
+ else
44
+ puts_line "Carson #{Carson::VERSION} — Portfolio (#{repositories.length} repo#{plural_suffix( count: repositories.length )})"
45
+ puts_line ""
46
+ results.each { |result| print_portfolio_status( result: result ) }
79
47
  end
80
48
 
81
49
  EXIT_OK
82
50
  end
83
51
 
84
- # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
85
52
  private
86
- # rubocop:enable Layout/AccessModifierIndentation
87
53
 
88
- # Returns an array of human-readable pending descriptions for a repo.
89
- def status_pending_for_repo( all_pending:, repo_path: )
90
- descriptions = []
91
- all_pending.each do |command, repos|
92
- next unless repos.is_a?( Hash ) && repos.key?( repo_path )
93
-
94
- info = repos[ repo_path ]
95
- attempts = info.fetch( "attempts", 0 )
96
- skipped_at = info.fetch( "skipped_at", nil )
97
- time_part = skipped_at ? ", since #{skipped_at[ 11..15 ]}" : ""
98
- descriptions << "#{command} (#{attempts} attempt#{attempts == 1 ? '' : 's'}#{time_part})"
99
- end
100
- descriptions
101
- end
102
-
103
- # Collects all status facets into a structured hash.
104
54
  def gather_status
105
- data = {
55
+ repository = repository_record
56
+ branch = branch_record
57
+ deliveries = ledger.active_deliveries( repo_path: repo_root )
58
+
59
+ {
106
60
  version: Carson::VERSION,
107
- branch: gather_branch_info,
108
- worktrees: gather_worktree_info,
109
- governance: gather_governance_info
61
+ repository: {
62
+ name: repository.name,
63
+ path: repository.path,
64
+ authority: repository.authority
65
+ },
66
+ branch: {
67
+ name: branch.name,
68
+ head: branch.head,
69
+ worktree: branch.worktree,
70
+ dirty: working_tree_dirty?,
71
+ dirty_reason: dirty_worktree_reason,
72
+ sync: remote_sync_status( branch: branch.name )
73
+ },
74
+ branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery ) },
75
+ stale_branches: gather_stale_branch_info
110
76
  }
111
-
112
- # PR and stale branch data require gh — gather with graceful fallback.
113
- if gh_available?
114
- data[ :pull_requests ] = gather_pr_info
115
- data[ :stale_branches ] = gather_stale_branch_info
116
- end
117
-
118
- data
119
77
  end
120
78
 
121
- # Branch name, clean/dirty state, sync status with remote.
122
- def gather_branch_info
123
- branch = current_branch
124
- dirty_reason = dirty_worktree_reason
125
- sync = remote_sync_status( branch: branch )
126
-
127
- { name: branch, dirty: !dirty_reason.nil?, dirty_reason: dirty_reason, sync: sync }
79
+ def status_branch_entry( delivery: )
80
+ {
81
+ branch: delivery.branch,
82
+ worktree_path: delivery.worktree_path,
83
+ head: delivery.head,
84
+ pr_number: delivery.pull_request_number,
85
+ delivery_state: delivery.status,
86
+ revision_count: delivery.revision_count,
87
+ summary: delivery.summary,
88
+ updated_at: delivery.updated_at
89
+ }
128
90
  end
129
91
 
130
- # Returns true when the working tree has uncommitted changes.
131
92
  def working_tree_dirty?
132
93
  stdout, _, success, = git_run( "status", "--porcelain" )
133
94
  return true unless success
@@ -145,92 +106,22 @@ module Carson
145
106
  realpath_safe( repo_root ) == realpath_safe( main_worktree_root )
146
107
  end
147
108
 
148
- # Compares local branch against its remote tracking ref.
149
- # Returns :in_sync, :ahead, :behind, :diverged, or :no_remote.
150
109
  def remote_sync_status( branch: )
151
110
  remote = config.git_remote
152
111
  remote_ref = "#{remote}/#{branch}"
153
-
154
- # Check if the remote ref exists.
155
112
  _, _, exists, = git_run( "rev-parse", "--verify", remote_ref )
156
113
  return :no_remote unless exists
157
114
 
158
115
  ahead_behind, _, success, = git_run( "rev-list", "--left-right", "--count", "#{branch}...#{remote_ref}" )
159
116
  return :unknown unless success
160
117
 
161
- parts = ahead_behind.strip.split( /\s+/ )
162
- ahead = parts[ 0 ].to_i
163
- behind = parts[ 1 ].to_i
164
-
118
+ ahead, behind = ahead_behind.strip.split.map( &:to_i )
165
119
  return :in_sync if ahead.zero? && behind.zero?
166
120
  return :ahead if behind.zero?
167
121
  return :behind if ahead.zero?
168
122
  :diverged
169
123
  end
170
124
 
171
- # Lists all worktrees with branch name.
172
- def gather_worktree_info
173
- entries = worktree_list
174
-
175
- # Filter output the main worktree (the repository root itself).
176
- # Use realpath for comparison — git returns canonical paths that may differ from repo_root.
177
- canonical_root = realpath_safe( repo_root )
178
- entries.reject { it.path == canonical_root }.map do |worktree|
179
- {
180
- path: worktree.path,
181
- name: File.basename( worktree.path ),
182
- branch: worktree.branch
183
- }
184
- end
185
- end
186
-
187
- # Queries open PRs via gh.
188
- def gather_pr_info
189
- stdout, _, success, = gh_run(
190
- "pr", "list", "--state", "open",
191
- "--json", "number,title,headRefName,statusCheckRollup,reviewDecision"
192
- )
193
- return [] unless success
194
-
195
- prs = JSON.parse( stdout ) rescue []
196
- prs.map do |pr|
197
- ci = summarise_checks( rollup: pr[ "statusCheckRollup" ] )
198
- review = pr[ "reviewDecision" ].to_s
199
- review_label = review_decision_label( decision: review )
200
-
201
- {
202
- number: pr[ "number" ],
203
- title: pr[ "title" ],
204
- branch: pr[ "headRefName" ],
205
- ci: ci,
206
- review: review_label
207
- }
208
- end
209
- end
210
-
211
- # Summarises check rollup into a single status word.
212
- def summarise_checks( rollup: )
213
- entries = Array( rollup )
214
- return :none if entries.empty?
215
-
216
- states = entries.map { it[ "conclusion" ].to_s.upcase }
217
- return :fail if states.any? { it == "FAILURE" || it == "CANCELLED" || it == "TIMED_OUT" }
218
- return :pending if states.any? { it == "" || it == "PENDING" || it == "QUEUED" || it == "IN_PROGRESS" }
219
-
220
- :pass
221
- end
222
-
223
- # Translates GitHub review decision to a concise label.
224
- def review_decision_label( decision: )
225
- case decision.upcase
226
- when "APPROVED" then :approved
227
- when "CHANGES_REQUESTED" then :changes_requested
228
- when "REVIEW_REQUIRED" then :review_required
229
- else :none
230
- end
231
- end
232
-
233
- # Counts local branches that are stale (tracking a deleted upstream).
234
125
  def gather_stale_branch_info
235
126
  stdout, _, success, = git_run( "branch", "-vv" )
236
127
  return { count: 0 } unless success
@@ -239,91 +130,61 @@ module Carson
239
130
  { count: gone_branches.size }
240
131
  end
241
132
 
242
- # Quick governance health check: are templates in sync?
243
- def gather_governance_info
244
- result = with_captured_output { template_check! }
245
- {
246
- templates: result == EXIT_OK ? :in_sync : :drifted
247
- }
248
- rescue StandardError
249
- { templates: :unknown }
250
- end
251
-
252
- # Prints the human-readable status report.
253
133
  def print_status( data: )
254
134
  puts_line "Carson #{data.fetch( :version )}"
255
- puts_line ""
135
+ puts_line "Repository: #{data.dig( :repository, :name )}"
136
+ puts_line "Authority: #{data.dig( :repository, :authority )}"
256
137
 
257
- # Branch
258
138
  branch = data.fetch( :branch )
259
- dirty_marker = format_dirty_marker( branch: branch )
260
- sync_marker = format_sync( sync: branch.fetch( :sync ) )
261
- puts_line "Branch: #{branch.fetch( :name )}#{dirty_marker}#{sync_marker}"
262
- if branch.fetch( :dirty_reason, nil ) == "main_worktree"
263
- puts_line "Governance: main working tree has uncommitted changes — create a worktree with `carson worktree create <name>`."
139
+ branch_line = "Branch: #{branch.fetch( :name )}"
140
+ branch_line += " (dirty #{branch.fetch( :dirty_reason )})" if branch.fetch( :dirty )
141
+ branch_line += " [#{format_sync( sync: branch.fetch( :sync ) )}]"
142
+ puts_line branch_line
143
+
144
+ deliveries = data.fetch( :branches )
145
+ if deliveries.empty?
146
+ puts_line "Deliveries: none"
147
+ return
264
148
  end
265
149
 
266
- # Worktrees
267
- worktrees = data.fetch( :worktrees )
268
- if worktrees.any?
269
- puts_line ""
270
- puts_line "Worktrees:"
271
- worktrees.each do |worktree|
272
- branch_label = worktree.fetch( :branch ) || "(detached)"
273
- puts_line " #{worktree.fetch( :name )} #{branch_label}"
274
- end
150
+ puts_line "Deliveries:"
151
+ deliveries.each do |delivery|
152
+ line = "- #{delivery.fetch( :delivery_state )}: #{delivery.fetch( :branch )}"
153
+ pr_number = delivery.fetch( :pr_number )
154
+ line += " (#{"PR ##{pr_number}"} )" if pr_number
155
+ puts_line line.gsub( " )", ")" )
156
+ puts_line " #{delivery.fetch( :summary )}" unless delivery.fetch( :summary ).to_s.empty?
275
157
  end
158
+ end
276
159
 
277
- # Pull requests
278
- prs = data.fetch( :pull_requests, nil )
279
- if prs && prs.any?
280
- puts_line ""
281
- puts_line "Pull requests:"
282
- prs.each do |pr|
283
- ci_label = pr.fetch( :ci ).to_s
284
- review_label = pr.fetch( :review ).to_s.tr( "_", " " )
285
- puts_line " ##{pr.fetch( :number )} #{pr.fetch( :title )}"
286
- puts_line " CI: #{ci_label} Review: #{review_label}"
287
- end
160
+ def print_portfolio_status( result: )
161
+ if result.fetch( :status ) == "error"
162
+ puts_line "#{result.fetch( :name )}: #{result.fetch( :error )}"
163
+ return
288
164
  end
289
165
 
290
- # Stale branches
291
- stale = data.fetch( :stale_branches, nil )
292
- if stale && stale.fetch( :count ) > 0
293
- count = stale.fetch( :count )
294
- puts_line ""
295
- puts_line "#{count} stale #{ count == 1 ? 'branch' : 'branches' } ready for pruning."
296
- end
297
-
298
- # Governance
299
- gov = data.fetch( :governance )
300
- templates = gov.fetch( :templates )
301
- unless templates == :in_sync
302
- puts_line ""
303
- puts_line "Templates: #{templates} — run `carson sync` to fix."
166
+ deliveries = Array( result.fetch( :branches, [] ) )
167
+ counts = deliveries.each_with_object( Hash.new( 0 ) ) { |delivery, memo| memo[ delivery.fetch( :delivery_state ) ] += 1 }
168
+ summary = if counts.empty?
169
+ "no active deliveries"
170
+ else
171
+ counts.map { |state, count| "#{count} #{state}" }.join( ", " )
304
172
  end
173
+ puts_line "#{result.fetch( :name )}: #{result.dig( :repository, :authority )} — #{summary}"
305
174
  end
306
175
 
307
- # Formats sync status for display.
308
176
  def format_sync( sync: )
309
177
  case sync
310
- when :in_sync then ""
311
- when :ahead then " (ahead of remote)"
312
- when :behind then " (behind remote)"
313
- when :diverged then " (diverged from remote)"
314
- when :no_remote then " (no remote tracking)"
315
- else ""
178
+ when :in_sync then "in sync"
179
+ when :ahead then "ahead"
180
+ when :behind then "behind"
181
+ when :diverged then "diverged"
182
+ when :no_remote then "no remote"
183
+ else "unknown"
316
184
  end
317
185
  end
186
+ end
318
187
 
319
- def format_dirty_marker( branch: )
320
- return "" unless branch.fetch( :dirty )
321
- return " (dirty main worktree)" if branch.fetch( :dirty_reason, nil ) == "main_worktree"
322
-
323
- " (dirty)"
324
- end
325
- end
326
-
327
- include Status
188
+ include Status
328
189
  end
329
190
  end
@@ -33,15 +33,22 @@ module Carson
33
33
  @config = Config.load( repo_root: repo_root )
34
34
  @git_adapter = Adapters::Git.new( repo_root: repo_root )
35
35
  @github_adapter = Adapters::GitHub.new( repo_root: repo_root )
36
+ @ledger = Ledger.new( path: @config.govern_state_path )
36
37
  @template_sync_result = nil
37
38
  end
38
39
 
39
- attr_reader :template_sync_result
40
+ attr_reader :template_sync_result, :ledger
40
41
 
41
42
  private
42
43
 
43
44
  attr_reader :repo_root, :tool_root, :output, :error, :in, :config, :git_adapter, :github_adapter
44
45
 
46
+ # Ruby 2.6 treats bare `in` awkwardly because of pattern-matching parsing.
47
+ # Keep the original ivar/reader for compatibility, but expose a safe helper name.
48
+ def input_stream
49
+ instance_variable_get( :@in )
50
+ end
51
+
45
52
  # Returns true when full diagnostic output is enabled via --verbose.
46
53
  def verbose?
47
54
  @verbose
@@ -74,6 +81,21 @@ module Carson
74
81
  git_capture!( "rev-parse", "--abbrev-ref", "HEAD" ).strip
75
82
  end
76
83
 
84
+ # Current branch head SHA for delivery identity.
85
+ def current_head
86
+ git_capture!( "rev-parse", "HEAD" ).strip
87
+ end
88
+
89
+ # Passive repository record for the current runtime context.
90
+ def repository_record
91
+ Repository.new( path: repo_root, authority: config.govern_authority, runtime: self )
92
+ end
93
+
94
+ # Passive branch record for the current checkout.
95
+ def branch_record( name: current_branch )
96
+ repository_record.branch( name ).reload
97
+ end
98
+
77
99
  # Checks local branch existence before restore attempts in ensure blocks.
78
100
  def branch_exists?( branch_name: )
79
101
  _, _, success, = git_run( "show-ref", "--verify", "--quiet", "refs/heads/#{branch_name}" )
@@ -355,6 +377,7 @@ module Carson
355
377
  class Runtime
356
378
  public :config, :output, :verbose?, :puts_verbose, :puts_line,
357
379
  :git_run, :git_capture!, :main_worktree_root, :realpath_safe,
358
- :block_if_outsider_fingerprints!, :branch_absorbed_into_main?
380
+ :block_if_outsider_fingerprints!, :branch_absorbed_into_main?,
381
+ :ledger, :current_head, :repository_record, :branch_record
359
382
  end
360
383
  end
@@ -52,13 +52,13 @@ module Carson
52
52
  # Compares using realpath to handle symlink differences.
53
53
  def self.find( path:, runtime: )
54
54
  canonical = runtime.realpath_safe( path )
55
- list( runtime: runtime ).find { it.path == canonical }
55
+ list( runtime: runtime ).find { |worktree| worktree.path == canonical }
56
56
  end
57
57
 
58
58
  # Returns true if the path is a registered git worktree.
59
59
  def self.registered?( path:, runtime: )
60
60
  canonical = runtime.realpath_safe( path )
61
- list( runtime: runtime ).any? { it.path == canonical }
61
+ list( runtime: runtime ).any? { |worktree| worktree.path == canonical }
62
62
  end
63
63
 
64
64
  # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
@@ -247,10 +247,10 @@ module Carson
247
247
  main_root = runtime.main_worktree_root
248
248
  worktrees = list( runtime: runtime )
249
249
 
250
- agent_prefixes = AGENT_DIRS.filter_map do |dir|
250
+ agent_prefixes = AGENT_DIRS.map do |dir|
251
251
  full = File.join( main_root, dir, "worktrees" )
252
252
  File.join( runtime.realpath_safe( full ), "" ) if Dir.exist?( full )
253
- end
253
+ end.compact
254
254
  return if agent_prefixes.empty?
255
255
 
256
256
  worktrees.each do |worktree|
@@ -454,7 +454,7 @@ module Carson
454
454
  return canonical if registered?( path: canonical, runtime: runtime )
455
455
 
456
456
  # Bare name didn't match flat layout — search registered worktrees by dirname.
457
- matches = list( runtime: runtime ).select { File.basename( it.path ) == path }
457
+ matches = list( runtime: runtime ).select { |worktree| File.basename( worktree.path ) == path }
458
458
  return matches.first.path if matches.size == 1
459
459
 
460
460
  # No match or ambiguous — return the flat candidate and let the caller
data/lib/carson.rb CHANGED
@@ -5,6 +5,11 @@ module Carson
5
5
  BADGE = "\u29D3".freeze # ⧓ BLACK BOWTIE (U+29D3)
6
6
  end
7
7
 
8
+ require_relative "carson/repository"
9
+ require_relative "carson/branch"
10
+ require_relative "carson/delivery"
11
+ require_relative "carson/revision"
12
+ require_relative "carson/ledger"
8
13
  require_relative "carson/worktree"
9
14
  require_relative "carson/config"
10
15
  require_relative "carson/adapters/git"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.22.1
4
+ version: 3.23.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -10,7 +10,27 @@ authors:
10
10
  bindir: exe
11
11
  cert_chain: []
12
12
  date: 1980-01-02 00:00:00.000000000 Z
13
- dependencies: []
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sqlite3
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '1.3'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '3'
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - ">="
29
+ - !ruby/object:Gem::Version
30
+ version: '1.3'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '3'
14
34
  description: 'Carson is an autonomous git strategist and repositories governor that
15
35
  lives outside the repositories it governs — no Carson-owned artefacts in your repo.
16
36
  As strategist, Carson knows when to branch, how to isolate concurrent work, and
@@ -48,8 +68,13 @@ files:
48
68
  - lib/carson/adapters/git.rb
49
69
  - lib/carson/adapters/github.rb
50
70
  - lib/carson/adapters/prompt.rb
71
+ - lib/carson/branch.rb
51
72
  - lib/carson/cli.rb
52
73
  - lib/carson/config.rb
74
+ - lib/carson/delivery.rb
75
+ - lib/carson/ledger.rb
76
+ - lib/carson/repository.rb
77
+ - lib/carson/revision.rb
53
78
  - lib/carson/runtime.rb
54
79
  - lib/carson/runtime/audit.rb
55
80
  - lib/carson/runtime/deliver.rb