carson 3.30.3 → 4.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 414294f12e0a6e1560b7c60df2ee77676734a63f279e40675bd4d10b55660851
4
- data.tar.gz: 2dafbe3def3939e75263fb47de9e474d073f083adbb8fb22ec997ff839727768
3
+ metadata.gz: 96b7009510f859ab0892bffc34678701dc74412a4f4b9f17f1be3710cb82d0c3
4
+ data.tar.gz: 236de5bb54d69845b4710b8ffe946a64534a1db5ea068e2a8e34d566d6c438c4
5
5
  SHA512:
6
- metadata.gz: fbc81621f5aa779bf26ef20316083c1e207266f99bbefaba4fa78eea4c475745c67f30410b66e4a29257ed88b54400068a8cfb6d40e1aabeda16615b9baad3bf
7
- data.tar.gz: 973068e6d3e716f44e7a86fed848eeab6c5f26b95839e414061ed9f745ef5ed79a000ec60d753294cd27844bd6878beb14c3739ac24e4cdac04f2510592212d5
6
+ metadata.gz: 334f80f636c8285fbb7e3bb4481b99efecf0b69700b337ad03a63cc1b9fb0fe0d1ab49765371aa57ede881958cb0d2d9f9cff9104b50d007352edd94fb1cd22e
7
+ data.tar.gz: b6a92f602a9d0fdd9b22ba320616c6fe2db66d51230895b88e32962edaa5c43cfcadfe828278afbeb4111585b10d04781708c2d17dcdb03df10320fa1674e6c9
data/RELEASE.md CHANGED
@@ -15,6 +15,25 @@ Release-note scope rule:
15
15
  - `--all` removed from all repo commands; use `carson list --json` to script batch operations
16
16
  - `refresh` is now portfolio-only (always refreshes all governed repos)
17
17
 
18
+ ## 4.0.0
19
+
20
+ ### Breaking
21
+
22
+ - **Courier waits at the registry** — `carson deliver` now polls the bureau up to 6 times (default 30s interval) instead of checking once and leaving. PRs that pass CI are merged automatically without re-running `carson deliver`. Agents that relied on instant return from `carson deliver` should expect it to block for up to 3 minutes.
23
+ - **Hold reasons renamed** — JSON output field `hold_reason` values changed: `inspector_pending` → `pending_at_registry`, `inspector_failed` → `failed_at_registry`, `inspector_error` → `error_at_registry`. Agents parsing these values must update.
24
+ - **Hold messages include recovery commands** — Human output for held deliveries now shows actionable recovery steps (e.g. `→ git rebase origin/main` then `→ carson deliver`) instead of prose descriptions.
25
+ - **Bureau model simplified** — No more "customs" or "inspector" in the domain model. The bureau is a registry where bureaucrats check parcels. Internal only — no user-facing impact beyond the hold_reason rename above.
26
+
27
+ ### New
28
+
29
+ - **`deliver.poll_interval_at_registry` config** — Controls how long the courier waits between registry checks (default 30 seconds). Override via config or `CARSON_POLL_INTERVAL_AT_REGISTRY` environment variable.
30
+ - **`filed` outcome** — When the courier exhausts all checks without a definitive answer, it reports "filed" with `→ carson status` as the next step, instead of the old "deferred" with `→ carson deliver`.
31
+
32
+ ### Migration
33
+
34
+ - If you parse `hold_reason` from JSON output, update: `inspector_pending` → `pending_at_registry`, `inspector_failed` → `failed_at_registry`, `inspector_error` → `error_at_registry`.
35
+ - `carson deliver` now takes up to ~3 minutes (6 checks × 30s). Adjust timeouts in CI or automation scripts if needed.
36
+
18
37
  ## 3.30.3
19
38
 
20
39
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.30.3
1
+ 4.0.0
data/lib/carson/config.rb CHANGED
@@ -32,7 +32,8 @@ module Carson
32
32
  :workflow_style,
33
33
  :govern_repos, :govern_merge_method,
34
34
  :govern_agent_provider, :govern_state_path,
35
- :govern_check_wait
35
+ :govern_check_wait,
36
+ :poll_interval_at_registry
36
37
 
37
38
  def self.load( repo_root: )
38
39
  base_data = default_data
@@ -81,7 +82,10 @@ module Carson
81
82
  "audit" => {
82
83
  "advisory_check_names" => [ "Scheduled review sweep", "Carson governance", "Tag, release, publish" ]
83
84
  },
84
- "govern" => {
85
+ "deliver" => {
86
+ "poll_interval_at_registry" => 30
87
+ },
88
+ "govern" => {
85
89
  "repos" => [],
86
90
  "merge" => {
87
91
  "method" => "squash"
@@ -167,6 +171,8 @@ module Carson
167
171
  audit = fetch_hash_section( data: copy, key: "audit" )
168
172
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
169
173
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
174
+ deliver = fetch_hash_section( data: copy, key: "deliver" )
175
+ deliver[ "poll_interval_at_registry" ] = env_integer( key: "CARSON_POLL_INTERVAL_AT_REGISTRY", fallback: deliver.fetch( "poll_interval_at_registry" ) )
170
176
  govern = fetch_hash_section( data: copy, key: "govern" )
171
177
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
172
178
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
@@ -238,6 +244,9 @@ module Carson
238
244
  audit_hash = fetch_hash( hash: data, key: "audit" )
239
245
  @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
240
246
 
247
+ deliver_hash = fetch_hash( hash: data, key: "deliver" )
248
+ @poll_interval_at_registry = fetch_non_negative_integer( hash: deliver_hash, key: "poll_interval_at_registry" )
249
+
241
250
  govern_hash = fetch_hash( hash: data, key: "govern" )
242
251
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
243
252
  govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
@@ -0,0 +1,259 @@
1
+ # Carson Co.
2
+ module Carson
3
+ # The delivery person — picks up parcels and delivers them to the registry.
4
+ #
5
+ # The courier is a Carson employee assigned to a warehouse. They pick up
6
+ # a parcel, ship it to the bureau, file a waybill, and wait at the
7
+ # registry while the bureaucrats check it.
8
+ #
9
+ # The courier is a thin orchestrator: it creates a Waybill and sends it
10
+ # messages. The domain logic lives in the objects, not the courier.
11
+ #
12
+ # == The bureau
13
+ #
14
+ # The bureau is a registry (GitHub) where bureaucrats work. They check
15
+ # parcels (CI, review, mergeability) and either accept them into the
16
+ # registry or hold them with a reason.
17
+ #
18
+ # == Situations the courier encounters
19
+ #
20
+ # Each numbered situation is handled by a specific guard or branch in the
21
+ # delivery flow. The number appears in the code comment where it's handled.
22
+ #
23
+ # 01. Parcel on main — cannot deliver from the destination.
24
+ # 02. Parcel behind standard — not based on client's latest standard.
25
+ # 03. Shipping fails — warehouse couldn't push to the bureau.
26
+ # 04. Waybill filing fails — bureau rejected the paperwork.
27
+ # 05. Pending at registry — bureaucrats still checking (CI running).
28
+ # 06. Failed at registry — bureaucrats rejected (CI failed).
29
+ # 07. Review pending — review still in progress.
30
+ # 08. Review changes requested — reviewer wants corrections.
31
+ # 09. Merge conflict — parcel conflicts with registry contents.
32
+ # 10. Behind standard (post-filing) — standard changed since shipping.
33
+ # 11. Policy block — bureau regulation prevents acceptance.
34
+ # 12. Draft waybill — form not finalised.
35
+ # 13. Mergeability pending — bureau still processing eligibility.
36
+ # 14. Acceptance succeeds — parcel enters the registry. Delivered.
37
+ # 15. Acceptance fails — classify why, report.
38
+ # 16. Bureau unreachable — cannot contact the bureau.
39
+ # 17. Parcel already delivered — already in registry.
40
+ # 18. Waybill closed — cancelled by someone externally.
41
+ #
42
+ # == Design: wait and poll at the registry
43
+ #
44
+ # The courier waits at the registry while the bureaucrats check the parcel.
45
+ # It polls up to MAX_CHECKS_AT_REGISTRY times, pausing between each check.
46
+ # If the bureaucrats give a definitive answer (accepted or rejected), the
47
+ # courier acts immediately. If the checks are exhausted without a definitive
48
+ # answer, the courier reports "filed" — the parcel is still at the registry.
49
+ #
50
+ # == Future: destination modes
51
+ #
52
+ # Currently remote-centred (ship → waybill → registry → acceptance).
53
+ # A future local-centred mode merges locally; remote is a synced backup.
54
+ # The destination mode should be injectable, not baked in.
55
+ class Courier
56
+ # Exit codes — shared contract between Carson employees and the CLI.
57
+ OK = 0
58
+ ERROR = 1
59
+ BLOCKED = 2
60
+
61
+ # The courier checks the registry up to 6 times before leaving.
62
+ MAX_CHECKS_AT_REGISTRY = 6
63
+
64
+ def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_registry: 30 )
65
+ @warehouse = warehouse
66
+ @ledger = ledger
67
+ @merge_method = merge_method
68
+ @poll_interval_at_registry = poll_interval_at_registry
69
+ end
70
+
71
+ # Deliver a parcel to the registry.
72
+ # Ships it, files a waybill, waits at the registry for the bureaucrats.
73
+ def deliver( parcel, title: nil, body_file: nil, commit_message: nil )
74
+ result = {
75
+ command: "deliver",
76
+ label: parcel.label,
77
+ remote_main: "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
78
+ }
79
+
80
+ # 01. Parcel on main — cannot deliver from the destination.
81
+ if parcel.on_main?( @warehouse.main_label )
82
+ return blocked( result,
83
+ "cannot deliver from #{@warehouse.main_label}",
84
+ recovery: "carson worktree create <name>" )
85
+ end
86
+
87
+ # Dirty tree guard — the warehouse knows if its floor is clean.
88
+ if commit_message && @warehouse.clean?
89
+ return blocked( result,
90
+ "working tree is already clean",
91
+ recovery: "carson deliver" )
92
+ end
93
+ if !commit_message && !@warehouse.clean?
94
+ return blocked( result,
95
+ "working tree is dirty",
96
+ recovery: "carson deliver --commit \"describe this delivery\"" )
97
+ end
98
+
99
+ # Submit compliance — ensure templates are in sync before delivery.
100
+ compliance = @warehouse.submit_compliance!
101
+ unless compliance[ :compliant ]
102
+ return error( result, compliance[ :error ] || "compliance check failed" )
103
+ end
104
+
105
+ # Pack the parcel if the sender provided a commit message.
106
+ # Skip if compliance already committed everything (tree is now clean).
107
+ if commit_message && !@warehouse.clean?
108
+ unless @warehouse.pack!( message: commit_message )
109
+ return error( result, "packing failed — nothing to commit?" )
110
+ end
111
+ end
112
+ # Refresh parcel head — compliance or pack may have created commits.
113
+ parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
114
+
115
+ # 02. Parcel behind standard — not based on client's latest standard.
116
+ @warehouse.fetch_latest( registry: @warehouse.main_label )
117
+ unless @warehouse.based_on_latest_standard?( parcel )
118
+ remote_main = "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
119
+ return blocked( result,
120
+ "branch is behind #{remote_main}",
121
+ recovery: "git rebase #{remote_main}, then carson deliver" )
122
+ end
123
+
124
+ # The courier picks up the parcel — start tracking.
125
+ record( parcel, status: "preparing", summary: "delivery accepted" )
126
+
127
+ # 03. Shipping fails — warehouse couldn't push to the bureau.
128
+ unless @warehouse.ship( parcel )
129
+ return error( result, "push failed" )
130
+ end
131
+
132
+ # File a waybill with the bureau.
133
+ waybill = Waybill.new(
134
+ label: parcel.label,
135
+ warehouse_path: @warehouse.path
136
+ )
137
+ waybill.file!( title: title, body_file: body_file )
138
+
139
+ # 04. Waybill filing fails — bureau rejected the paperwork.
140
+ unless waybill.filed?
141
+ return error( result, "PR creation failed", recovery: "carson deliver" )
142
+ end
143
+
144
+ result[ :tracking_number ] = waybill.tracking_number
145
+ result[ :url ] = waybill.url
146
+
147
+ # Wait at the registry while the bureaucrats check the parcel.
148
+ wait_and_poll_at_registry( waybill, result )
149
+
150
+ # Update the ledger with the final outcome.
151
+ record( parcel, status: result[ :outcome ] || "filed", summary: result[ :hold_reason ] )
152
+
153
+ result[ :exit ] ||= OK
154
+ result
155
+ end
156
+
157
+ private
158
+
159
+ # Wait at the registry, polling the bureaucrats up to MAX_CHECKS_AT_REGISTRY
160
+ # times. The courier stays until a definitive answer comes back or the
161
+ # checks are exhausted.
162
+ def wait_and_poll_at_registry( waybill, result )
163
+ MAX_CHECKS_AT_REGISTRY.times do |check|
164
+ waybill.refresh!
165
+
166
+ # 14/17. Already accepted — parcel is in the registry.
167
+ if waybill.accepted?
168
+ result[ :outcome ] = "delivered"
169
+ result[ :synced ] = @warehouse.receive_latest_standard!
170
+ return
171
+ end
172
+
173
+ # 18. Waybill closed — cancelled externally.
174
+ if waybill.rejected?
175
+ result[ :outcome ] = "rejected"
176
+ result[ :exit ] = BLOCKED
177
+ return
178
+ end
179
+
180
+ # Cleared or mergeability pending — try to accept.
181
+ if waybill.cleared? || waybill.mergeability_pending?
182
+ waybill.accept!( method: @merge_method )
183
+
184
+ if waybill.accepted?
185
+ result[ :outcome ] = "delivered"
186
+ result[ :synced ] = @warehouse.receive_latest_standard!
187
+ return
188
+ end
189
+ end
190
+
191
+ # 05-12. Definitively blocked — courier takes parcel back.
192
+ if definitively_blocked?( waybill )
193
+ result[ :outcome ] = "held"
194
+ result[ :exit ] = BLOCKED
195
+ result[ :hold_reason ] = waybill.hold_reason
196
+ return
197
+ end
198
+
199
+ # Still waiting — pause before the next check.
200
+ pause_between_polls unless check == MAX_CHECKS_AT_REGISTRY - 1
201
+ end
202
+
203
+ # Exhausted all checks — bureau hasn't given a definitive answer.
204
+ result[ :outcome ] = "filed"
205
+ result[ :hold_reason ] = waybill.hold_reason
206
+ end
207
+
208
+ # Is the waybill blocked by something that won't resolve by waiting?
209
+ # CI failure, merge conflict, policy block — the courier should take
210
+ # the parcel back immediately.
211
+ def definitively_blocked?( waybill )
212
+ return false unless waybill.held?
213
+ reason = waybill.hold_reason
214
+ [ "failed_at_registry", "merge_conflict",
215
+ "behind_registry", "policy_block", "draft" ].include?( reason )
216
+ end
217
+
218
+ # Pause between poll checks. Overridable for test isolation.
219
+ def pause_between_polls
220
+ sleep @poll_interval_at_registry
221
+ end
222
+
223
+ # Record a delivery state change in the ledger.
224
+ # No-op when no ledger is injected (e.g. tests).
225
+ def record( parcel, status:, summary: nil )
226
+ return unless @ledger
227
+
228
+ # The ledger needs a repository-like object with .path pointing
229
+ # to the main warehouse root (not a side shelf).
230
+ repo = Struct.new( :path ).new( @warehouse.main_worktree_root )
231
+ @ledger.upsert_delivery(
232
+ repository: repo,
233
+ branch_name: parcel.label,
234
+ head: parcel.head,
235
+ worktree_path: @warehouse.path,
236
+ pr_number: nil,
237
+ pr_url: nil,
238
+ status: status,
239
+ summary: summary,
240
+ cause: nil
241
+ )
242
+ end
243
+
244
+ # Build a blocked result — the courier cannot proceed.
245
+ def blocked( result, message, recovery: nil )
246
+ result[ :exit ] = BLOCKED
247
+ result[ :error ] = message
248
+ result[ :recovery ] = recovery
249
+ result
250
+ end
251
+
252
+ def error( result, message, recovery: nil )
253
+ result[ :exit ] = ERROR
254
+ result[ :error ] = message
255
+ result[ :recovery ] = recovery
256
+ result
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,28 @@
1
+ # The committed changes on a branch — the thing being delivered.
2
+ #
3
+ # In the FedEx metaphor, a parcel sits on a shelf (worktree) identified
4
+ # by a label (branch name). Agents put committed changes — the parcel —
5
+ # on a branch, then call Carson to deliver it.
6
+ #
7
+ # The parcel does not deliver itself. The courier does that.
8
+ # The parcel does not pack itself. The warehouse does that.
9
+ module Carson
10
+ # The committed changes being delivered. Knows its label (branch),
11
+ # head (commit SHA), and shelf (worktree). The protagonist of every
12
+ # delivery — without a parcel, there is nothing to deliver.
13
+ class Parcel
14
+ attr_reader :label, :head, :shelf
15
+
16
+ def initialize( label:, head:, shelf: nil )
17
+ @label = label
18
+ @head = head
19
+ @shelf = shelf
20
+ end
21
+
22
+ # Is this parcel sitting on the main shelf?
23
+ # The main shelf is the destination — you cannot deliver FROM the destination.
24
+ def on_main?( main_label )
25
+ label == main_label
26
+ end
27
+ end
28
+ end
@@ -6,119 +6,61 @@ module Carson
6
6
  DELIVER_MERGE_ATTEMPT_CAP = 3
7
7
 
8
8
  # Entry point for `carson deliver`.
9
- # Pushes the current branch, ensures a PR exists, records delivery state,
10
- # waits for merge readiness, and integrates when the path is clear.
11
- # When --commit is supplied, Carson creates one all-dirty agent-authored commit first.
9
+ # Delegates to the OO domain model: Warehouse Courier Waybill.
10
+ # The Courier orchestrates the delivery; Carson renders the result.
12
11
  def deliver!( title: nil, body_file: nil, commit_message: nil, json_output: false )
13
- branch_name = current_branch
14
- main_branch = config.main_branch
15
- remote_name = config.git_remote
16
- result = {
17
- command: "deliver",
18
- branch: branch_name,
19
- git_remote: remote_name,
20
- watch_window_seconds: config.govern_check_wait.to_i,
21
- waited_seconds: 0,
22
- merge_attempted: false
23
- }
24
-
25
- if branch_name == main_branch
26
- result[ :error ] = "cannot deliver from #{main_branch}"
27
- result[ :recovery ] = "carson worktree create <name>"
28
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
29
- end
30
-
31
- initial_dirty = working_tree_dirty?
32
- if initial_dirty && commit_message.to_s.strip.empty?
33
- result[ :error ] = "working tree is dirty"
34
- result[ :recovery ] = "carson deliver --commit \"describe this delivery\""
35
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
36
- end
37
-
38
- if !initial_dirty && !commit_message.to_s.strip.empty?
39
- result[ :commit ] = blocked_commit_payload(
40
- message: commit_message,
41
- summary: "blocked — working tree is already clean"
42
- )
43
- result[ :error ] = "working tree is already clean"
44
- result[ :recovery ] = "carson deliver"
45
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
46
- end
47
-
48
- sync_exit, sync_diagnostics = deliver_template_sync
49
- if sync_exit == EXIT_ERROR
50
- result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
51
- return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
52
- end
53
- template_sync_committed = sync_exit == EXIT_BLOCK
54
-
55
- unless commit_message.to_s.strip.empty?
56
- commit_exit = prepare_delivery_commit!(
57
- commit_message: commit_message,
58
- template_sync_committed: template_sync_committed,
59
- result: result
60
- )
61
- return deliver_finish( result: result, exit_code: commit_exit, json_output: json_output ) unless commit_exit == EXIT_OK
62
- end
63
-
64
- freshness = assess_branch_freshness(
65
- head_ref: current_head,
66
- remote: remote_name,
67
- main: main_branch
12
+ warehouse = Warehouse.new(
13
+ path: work_dir,
14
+ main_label: config.main_branch,
15
+ bureau_address: config.git_remote,
16
+ compliance_checker: method( :deliver_compliance_checker )
68
17
  )
69
- result[ :freshness ] = freshness_payload( freshness: freshness )
70
- unless freshness.fetch( :ready )
71
- result[ :summary ] = freshness.fetch( :summary )
72
- result[ :error ] = freshness.fetch( :summary )
73
- result[ :recovery ] = freshness_recovery( freshness: freshness )
74
- result[ :main_branch ] = main_branch
75
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
76
- end
77
-
78
- push_exit = push_branch!( branch: branch_name, remote: remote_name, result: result )
79
- return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
18
+ parcel = Parcel.new(
19
+ label: current_branch,
20
+ head: current_head
21
+ )
22
+ courier = Courier.new( warehouse,
23
+ ledger: ledger,
24
+ merge_method: config.govern_merge_method,
25
+ poll_interval_at_registry: config.poll_interval_at_registry
26
+ )
80
27
 
81
- pr_number, pr_url = find_or_create_pr!(
82
- branch: branch_name,
28
+ result = courier.deliver( parcel,
83
29
  title: title,
84
30
  body_file: body_file,
85
- result: result
86
- )
87
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output ) if pr_number.nil?
88
-
89
- branch = branch_record( name: branch_name )
90
- delivery = ledger.upsert_delivery(
91
- repository: repository_record,
92
- branch_name: branch.name,
93
- head: branch.head || current_head,
94
- worktree_path: branch.worktree || repo_root,
95
- pr_number: pr_number,
96
- pr_url: pr_url,
97
- status: "preparing",
98
- summary: "delivery accepted",
99
- cause: nil
100
- )
101
- delivery = settle_delivery!(
102
- delivery: delivery,
103
- branch_name: branch.name,
104
- remote: remote_name,
105
- main: main_branch,
106
- result: result
31
+ commit_message: commit_message
107
32
  )
108
33
 
109
- result[ :pr_number ] = pr_number
110
- result[ :pr_url ] = pr_url
111
- result[ :ci ] = "pass" if delivery.integrated?
112
- result[ :delivery ] = delivery_payload( delivery: delivery )
113
- result[ :main_branch ] = main_branch
114
- result[ :summary ] = delivery.summary
115
- result[ :next_step ] = deliver_next_step( delivery: delivery, result: result )
116
-
117
- deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
34
+ deliver_oo_finish( result: result, json_output: json_output )
118
35
  end
119
36
 
120
37
  private
121
38
 
39
+ # --- OO bridge methods ---
40
+
41
+ # Compliance checker for the Warehouse. Wraps the existing template_apply!
42
+ # machinery and returns the hash contract submit_compliance! expects.
43
+ def deliver_compliance_checker( _warehouse )
44
+ sync_exit, sync_diagnostics = deliver_template_sync
45
+ case sync_exit
46
+ when EXIT_OK
47
+ { compliant: true, committed: false }
48
+ when EXIT_BLOCK
49
+ { compliant: true, committed: true }
50
+ else
51
+ { compliant: false, committed: false, error: sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip }
52
+ end
53
+ end
54
+
55
+ # Render the OO result — JSON or human via Carson.report.
56
+ def deliver_oo_finish( result:, json_output: )
57
+ format = json_output ? :json : :human
58
+ Carson.report( result, format: format, output: output )
59
+ result[ :exit ] || Courier::OK
60
+ end
61
+
62
+ # --- Legacy deliver methods (used by receive!, status!, etc.) ---
63
+
122
64
  def prepare_delivery_commit!( commit_message:, template_sync_committed:, result: )
123
65
  if working_tree_dirty?
124
66
  return create_delivery_commit!( commit_message: commit_message, result: result )
@@ -0,0 +1,151 @@
1
+ # A governed repository. In the FedEx metaphor, the warehouse is where
2
+ # parcels (committed changes) are stored on shelves (worktrees) with
3
+ # labels (branches). Git commands are hidden inside — callers never
4
+ # see git terms.
5
+ require "open3"
6
+
7
+ module Carson
8
+ # A governed repository — the warehouse where parcels are stored on
9
+ # shelves (worktrees) with labels (branches). Wraps git operations
10
+ # with story-language methods. An intelligent warehouse that manages
11
+ # itself: packing parcels, checking compliance, and sweeping up.
12
+ class Warehouse
13
+ attr_reader :path
14
+
15
+ def initialize( path:, main_label: "main", bureau_address: "github", compliance_checker: nil )
16
+ @path = path
17
+ @main_label = main_label
18
+ @bureau_address = bureau_address
19
+ @compliance_checker = compliance_checker
20
+ end
21
+
22
+ # --- What the warehouse knows ---
23
+
24
+ # The label on the current shelf (branch name).
25
+ def current_label
26
+ git( "rev-parse", "--abbrev-ref", "HEAD" ).first.strip
27
+ end
28
+
29
+ # The tip of the parcel on the current shelf (commit SHA).
30
+ def current_head
31
+ git( "rev-parse", "HEAD" ).first.strip
32
+ end
33
+
34
+ # The destination label (from config).
35
+ def main_label
36
+ @main_label
37
+ end
38
+
39
+ # The bureau's address (remote name).
40
+ def bureau_address
41
+ @bureau_address
42
+ end
43
+
44
+ # Is the warehouse floor clean? No uncommitted changes on the current shelf.
45
+ def clean?
46
+ output, _, status = git( "status", "--porcelain" )
47
+ status.success? && output.strip.empty?
48
+ end
49
+
50
+ # --- Warehouse operations ---
51
+
52
+ # Ship a parcel to the bureau.
53
+ # The warehouse sends the parcel's label to the remote.
54
+ def ship( parcel, remote: bureau_address )
55
+ _, _, status = git( "push", "-u", remote, parcel.label )
56
+ status.success?
57
+ end
58
+
59
+ # Get latest registry state from the bureau (git fetch).
60
+ # Returns true on success, false on failure.
61
+ def fetch_latest( remote: bureau_address, registry: nil )
62
+ arguments = [ "fetch", remote ]
63
+ arguments << registry if registry
64
+ _, _, status = git( *arguments )
65
+ status.success?
66
+ end
67
+
68
+ # Is the parcel based on the client's latest production standard?
69
+ # Checks whether the registry tip is an ancestor of the parcel's head.
70
+ def based_on_latest_standard?( parcel, registry: "#{bureau_address}/#{main_label}" )
71
+ _, _, status = git( "merge-base", "--is-ancestor", registry, parcel.head )
72
+ status.success?
73
+ end
74
+
75
+ # Ensure the warehouse complies with company standards (template sync).
76
+ # Delegates to the injected compliance checker. If no checker is set,
77
+ # the warehouse assumes compliance — no templates to enforce.
78
+ # Returns a hash: { compliant: true/false, committed: true/false, error: nil/string }
79
+ def submit_compliance!
80
+ return { compliant: true, committed: false } unless @compliance_checker
81
+
82
+ @compliance_checker.call( self )
83
+ end
84
+
85
+ # Update the warehouse's production standard — rebase onto latest registry state.
86
+ # Called after the bureau refuses a parcel for being behind standard.
87
+ # Returns true on success, false on failure.
88
+ def rebase_on_latest_standard!( registry: "#{bureau_address}/#{main_label}" )
89
+ _, _, status = git( "rebase", registry )
90
+ status.success?
91
+ end
92
+
93
+ # Pack a parcel — stage all changes and commit.
94
+ # Returns true on success, false on failure.
95
+ def pack!( message: )
96
+ git( "add", "-A" )
97
+ _, _, status = git( "commit", "-m", message )
98
+ status.success?
99
+ end
100
+
101
+ # Receive the latest standard from the registry after a parcel is accepted.
102
+ # Fast-forwards local main without switching branches.
103
+ # Returns true on success, false on failure.
104
+ def receive_latest_standard!( remote: bureau_address )
105
+ _, _, status = Open3.capture3(
106
+ "git", "-C", main_worktree_root,
107
+ "fetch", remote, "#{main_label}:#{main_label}"
108
+ )
109
+ status.success?
110
+ end
111
+
112
+ # --- Inventory ---
113
+
114
+ # All shelves (worktree paths).
115
+ def shelves
116
+ output, = git( "worktree", "list", "--porcelain" )
117
+ output.lines
118
+ .select { it.start_with?( "worktree " ) }
119
+ .map { it.sub( "worktree ", "" ).strip }
120
+ end
121
+
122
+ # All labels (branch names).
123
+ def labels
124
+ output, = git( "branch", "--format", "%(refname:short)" )
125
+ output.lines.map { it.strip }.reject { it.empty? }
126
+ end
127
+
128
+ # Has this label been merged into main?
129
+ def label_absorbed?( name )
130
+ merged_output, = git( "branch", "--merged", main_label, "--format", "%(refname:short)" )
131
+ merged_output.lines.map { it.strip }.include?( name )
132
+ end
133
+
134
+ # The main warehouse location — resolves correctly even from a side shelf.
135
+ # Used by sync! and ledger recording to always reference the canonical path.
136
+ def main_worktree_root
137
+ git_common_dir, = git( "rev-parse", "--path-format=absolute", "--git-common-dir" )
138
+ common = git_common_dir.strip
139
+ # If it ends with /.git, the parent is the main worktree root.
140
+ common.end_with?( "/.git" ) ? File.dirname( common ) : common
141
+ end
142
+
143
+ private
144
+
145
+ # All git commands go through this single gateway.
146
+ # Returns [stdout, stderr, status].
147
+ def git( *arguments )
148
+ Open3.capture3( "git", "-C", path, *arguments )
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,243 @@
1
+ # The shipping document filed with the bureau (GitHub PR).
2
+ #
3
+ # The courier files a waybill with the bureau when delivering a parcel.
4
+ # The waybill has a tracking number (PR number), knows the bureau's
5
+ # response (cleared/held/accepted/rejected), and can ask the bureau
6
+ # to accept the parcel into the registry.
7
+ #
8
+ # The bureau is a registry where bureaucrats work. They check parcels
9
+ # (CI, review, mergeability) and either accept them into the registry
10
+ # or hold them with a reason.
11
+ #
12
+ # The waybill uses gh CLI internally — that's a tool, not the domain.
13
+ require "json"
14
+ require "open3"
15
+
16
+ module Carson
17
+ # The shipping document filed with the bureau (GitHub PR). Has a
18
+ # tracking number, knows the bureaucrats' response (cleared/held/
19
+ # accepted/rejected), and can ask the bureau to accept the parcel
20
+ # into the registry. Uses gh CLI internally — that's a tool, not
21
+ # the domain.
22
+ class Waybill
23
+ attr_reader :tracking_number, :url, :label
24
+
25
+ def initialize( label:, warehouse_path:, tracking_number: nil, url: nil, review_gate: nil )
26
+ @label = label
27
+ @warehouse_path = warehouse_path
28
+ @tracking_number = tracking_number
29
+ @url = url
30
+ @review_gate = review_gate
31
+ @state = nil
32
+ @ci = nil
33
+ end
34
+
35
+ # --- Filing ---
36
+
37
+ # Has the waybill been filed with the bureau?
38
+ def filed?
39
+ !tracking_number.nil?
40
+ end
41
+
42
+ # File the waybill with the bureau. Creates a PR on GitHub.
43
+ def file!( title: nil, body_file: nil )
44
+ filing_title = title || default_title
45
+ arguments = [ "pr", "create", "--title", filing_title, "--head", label ]
46
+
47
+ if body_file && File.exist?( body_file )
48
+ arguments.push( "--body-file", body_file )
49
+ else
50
+ arguments.push( "--body", "" )
51
+ end
52
+
53
+ stdout, stderr, success, = gh( *arguments )
54
+ if success
55
+ @url = stdout.to_s.strip
56
+ @tracking_number = @url.split( "/" ).last.to_i
57
+ @tracking_number = nil if @tracking_number == 0
58
+ end
59
+
60
+ # If create failed or returned no number, try to find existing.
61
+ find_existing! unless filed?
62
+ self
63
+ end
64
+
65
+ # Generate a human-readable title from the label.
66
+ def default_title
67
+ label.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) do |character|
68
+ character.upcase
69
+ end
70
+ end
71
+
72
+ # --- Bureau's response ---
73
+
74
+ # Check with the bureau for the latest on this waybill.
75
+ def refresh!
76
+ @state = fetch_state
77
+ @ci = fetch_ci
78
+ self
79
+ end
80
+
81
+ # Has the bureau accepted the parcel into the registry?
82
+ def accepted?
83
+ @state&.dig( "state" ) == "MERGED"
84
+ end
85
+
86
+ # Has the bureau rejected the waybill (closed without merge)?
87
+ def rejected?
88
+ @state&.dig( "state" ) == "CLOSED"
89
+ end
90
+
91
+ # Is the waybill still a draft?
92
+ def draft?
93
+ @state&.dig( "isDraft" ) || false
94
+ end
95
+
96
+ # Has the bureau cleared the parcel for delivery?
97
+ # All bureaucrats pass, no merge blocks, merge state is clean.
98
+ def cleared?
99
+ return false unless filed?
100
+ return false if draft?
101
+ return false unless @ci == :pass
102
+ return false if merge_conflicting? || merge_behind? || merge_policy_blocked?
103
+ merge_status = @state&.dig( "mergeStateStatus" ).to_s.upcase
104
+ mergeable = @state&.dig( "mergeable" ).to_s.upcase
105
+ merge_status == "CLEAN" || mergeable == "MERGEABLE"
106
+ end
107
+
108
+ # Is something blocking this waybill?
109
+ def held?
110
+ return false if cleared? || accepted? || rejected?
111
+ filed?
112
+ end
113
+
114
+ # Why is the waybill being held?
115
+ def hold_reason
116
+ return "draft" if draft?
117
+ return "pending_at_registry" if @ci == :pending
118
+ return "failed_at_registry" if @ci == :fail
119
+ return "error_at_registry" if @ci == :error
120
+ return "merge_conflict" if merge_conflicting?
121
+ return "behind_registry" if merge_behind?
122
+ return "policy_block" if merge_policy_blocked?
123
+ "mergeability_pending"
124
+ end
125
+
126
+ # Human-readable explanation of why the waybill is held.
127
+ def hold_summary
128
+ case hold_reason
129
+ when "draft" then "waybill is still a draft"
130
+ when "pending_at_registry" then "waiting for bureaucrats to check"
131
+ when "failed_at_registry" then "bureaucrats rejected the parcel"
132
+ when "error_at_registry" then "unable to reach the bureaucrats"
133
+ when "merge_conflict" then "parcel has conflicts with registry"
134
+ when "behind_registry" then "parcel is behind the registry"
135
+ when "policy_block" then "blocked by bureau policy"
136
+ else "waiting for bureau assessment"
137
+ end
138
+ end
139
+
140
+ # Is the hold specifically because mergeability is still pending?
141
+ def mergeability_pending?
142
+ hold_reason == "mergeability_pending"
143
+ end
144
+
145
+ # --- Acceptance ---
146
+
147
+ # Ask the bureau to accept the parcel into the registry.
148
+ # Updates own state after the attempt.
149
+ def accept!( method: )
150
+ gh( "pr", "merge", tracking_number.to_s, "--#{method}" )
151
+ refresh!
152
+ self
153
+ end
154
+
155
+ # --- Observation data for delivery records ---
156
+
157
+ # Returns a hash of the bureau's current state for tracking records.
158
+ def to_observation
159
+ return {} unless @state.is_a?( Hash )
160
+
161
+ {
162
+ pull_request_state: @state[ "state" ],
163
+ pull_request_draft: @state[ "isDraft" ],
164
+ pull_request_merged_at: @state[ "mergedAt" ]
165
+ }
166
+ end
167
+
168
+ # --- Test support ---
169
+
170
+ # Stub the bureau's response for testing without gh CLI.
171
+ def stub_bureau_response( state: nil, ci: nil )
172
+ @state = state if state
173
+ @ci = ci if ci
174
+ end
175
+
176
+ private
177
+
178
+ def merge_conflicting?
179
+ status = @state&.dig( "mergeStateStatus" ).to_s.upcase
180
+ mergeable = @state&.dig( "mergeable" ).to_s.upcase
181
+ mergeable == "CONFLICTING" || status == "DIRTY" || status == "CONFLICTING"
182
+ end
183
+
184
+ def merge_behind?
185
+ @state&.dig( "mergeStateStatus" ).to_s.upcase == "BEHIND"
186
+ end
187
+
188
+ def merge_policy_blocked?
189
+ @state&.dig( "mergeStateStatus" ).to_s.upcase == "BLOCKED"
190
+ end
191
+
192
+ def fetch_state
193
+ stdout, _, success, = gh(
194
+ "pr", "view", tracking_number.to_s,
195
+ "--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
196
+ )
197
+ return nil unless success
198
+
199
+ JSON.parse( stdout )
200
+ rescue JSON::ParserError
201
+ nil
202
+ end
203
+
204
+ def fetch_ci
205
+ stdout, _, success, = gh(
206
+ "pr", "checks", tracking_number.to_s,
207
+ "--json", "name,bucket"
208
+ )
209
+ return :error unless success
210
+
211
+ checks = JSON.parse( stdout ) rescue []
212
+ return :none if checks.empty?
213
+
214
+ buckets = checks.map do |entry|
215
+ entry[ "bucket" ].to_s.downcase
216
+ end
217
+ return :fail if buckets.include?( "fail" )
218
+ return :pending if buckets.include?( "pending" )
219
+
220
+ :pass
221
+ end
222
+
223
+ def find_existing!
224
+ stdout, _, success, = gh(
225
+ "pr", "view", label,
226
+ "--json", "number,url,state"
227
+ )
228
+ if success
229
+ data = JSON.parse( stdout ) rescue nil
230
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
231
+ @tracking_number = data[ "number" ]
232
+ @url = data[ "url" ].to_s
233
+ end
234
+ end
235
+ end
236
+
237
+ # All gh commands go through this single gateway.
238
+ def gh( *arguments )
239
+ stdout, stderr, status = Open3.capture3( "gh", *arguments, chdir: @warehouse_path )
240
+ [ stdout, stderr, status.success?, status.exitstatus ]
241
+ end
242
+ end
243
+ end
data/lib/carson.rb CHANGED
@@ -3,6 +3,78 @@ require_relative "carson/version"
3
3
 
4
4
  module Carson
5
5
  BADGE = "\u29D3".freeze # ⧓ BLACK BOWTIE (U+29D3)
6
+
7
+ # The company renders results for whoever is listening.
8
+ # JSON is the primary format (agents consume it). Human-readable is secondary.
9
+ # Domain objects return result hashes — Carson decides how to present them.
10
+ def self.report( result, format: :json, output: $stdout )
11
+ case format
12
+ when :json
13
+ require "json"
14
+ output.puts JSON.pretty_generate( result )
15
+ when :human
16
+ report_human( result, output: output )
17
+ end
18
+ end
19
+
20
+ # Human-readable delivery report — technical language for agents and humans.
21
+ # Story language is internal (source code). Output speaks the client's language.
22
+ def self.report_human( result, output: $stdout )
23
+ if result[ :error ]
24
+ output.puts "#{BADGE} #{result[ :error ]}"
25
+ output.puts " \u2192 #{result[ :recovery ]}" if result[ :recovery ]
26
+ return
27
+ end
28
+
29
+ output.puts "#{BADGE} Delivery: #{result[ :label ]}" if result[ :label ]
30
+ output.puts "#{BADGE} PR ##{result[ :tracking_number ]} #{result[ :url ]}" if result[ :tracking_number ]
31
+
32
+ remote_main = result[ :remote_main ] || "origin/main"
33
+
34
+ case result[ :outcome ]
35
+ when "delivered"
36
+ output.puts "#{BADGE} Merged."
37
+ output.puts "#{BADGE} Local main synced." if result[ :synced ]
38
+ when "held"
39
+ diagnosis, *recovery_steps = translate_hold( result[ :hold_reason ], remote_main: remote_main )
40
+ output.puts "#{BADGE} #{diagnosis}"
41
+ recovery_steps.each do |step|
42
+ output.puts " \u2192 #{step}"
43
+ end
44
+ when "rejected"
45
+ output.puts "#{BADGE} PR closed externally."
46
+ when "filed"
47
+ output.puts "#{BADGE} Bureau hasn't responded yet. Run carson status to check back."
48
+ end
49
+ end
50
+
51
+ # Translate internal hold reasons to agent-actionable output.
52
+ # Returns [ diagnosis, *recovery_steps ]. The diagnosis says what
53
+ # happened. Each recovery step is a command the agent can execute.
54
+ def self.translate_hold( reason, remote_main: "origin/main" )
55
+ case reason
56
+ when "draft"
57
+ [ "PR is still a draft." ]
58
+ when "pending_at_registry"
59
+ [ "Waiting for CI checks.", "carson status" ]
60
+ when "failed_at_registry"
61
+ [ "CI checks failed.", "carson deliver" ]
62
+ when "error_at_registry"
63
+ [ "Unable to assess CI checks.", "carson status" ]
64
+ when "merge_conflict"
65
+ [ "Merge conflict with #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
66
+ when "behind_registry"
67
+ [ "Branch is behind #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
68
+ when "policy_block"
69
+ [ "Blocked by branch protection rules." ]
70
+ when "mergeability_pending"
71
+ [ "GitHub is calculating mergeability.", "carson status" ]
72
+ else
73
+ [ "Waiting for merge readiness.", "carson status" ]
74
+ end
75
+ end
76
+
77
+ private_class_method :report_human
6
78
  end
7
79
 
8
80
  require_relative "carson/repository"
@@ -10,6 +82,10 @@ require_relative "carson/branch"
10
82
  require_relative "carson/delivery"
11
83
  require_relative "carson/revision"
12
84
  require_relative "carson/ledger"
85
+ require_relative "carson/parcel"
86
+ require_relative "carson/warehouse"
87
+ require_relative "carson/waybill"
88
+ require_relative "carson/courier"
13
89
  require_relative "carson/worktree"
14
90
  require_relative "carson/config"
15
91
  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.30.3
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -70,8 +70,10 @@ files:
70
70
  - lib/carson/branch.rb
71
71
  - lib/carson/cli.rb
72
72
  - lib/carson/config.rb
73
+ - lib/carson/courier.rb
73
74
  - lib/carson/delivery.rb
74
75
  - lib/carson/ledger.rb
76
+ - lib/carson/parcel.rb
75
77
  - lib/carson/repository.rb
76
78
  - lib/carson/revision.rb
77
79
  - lib/carson/runtime.rb
@@ -100,6 +102,8 @@ files:
100
102
  - lib/carson/runtime/setup.rb
101
103
  - lib/carson/runtime/status.rb
102
104
  - lib/carson/version.rb
105
+ - lib/carson/warehouse.rb
106
+ - lib/carson/waybill.rb
103
107
  - lib/carson/worktree.rb
104
108
  homepage: https://github.com/wanghailei/carson
105
109
  licenses: