carson 4.2.1 → 4.3.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: 251588ff2b1c952538965ea349b498e424c9646f6c57965dd516e7729e002035
4
- data.tar.gz: 2fee9945faa9fbace57ef59dfd77cf5fb9c38992f4e573e796daeef8b1972017
3
+ metadata.gz: 5d25d50d20c3cc3244b3ab246ca7f5b661e097146993fee1cc5b8dd66f978938
4
+ data.tar.gz: 1d2f6a26fc79dc62688c409557f2b74328f593f2e582af17c4a6fa020bb1b75b
5
5
  SHA512:
6
- metadata.gz: 3981e6facd927beb517b18151eb0d4804863e6adab4d4d39039e6e9313e644f57caae09e0b292e1e33d49b383e1f3f7975decfe3d0672dc372f3582b757bc513
7
- data.tar.gz: 55175ac0b094290e602d79854e65208fb5f1baa23753ed05c786c48ddda562b2015de76fb7244dc90c12a2495b2d84269e40c3f9521077d646776eeab843c760
6
+ metadata.gz: 234de9b2d4bad297af4b7bf24bd396629690bde5925fa743d9438316dcbb146f88e444b0aed61a72c022de32e34ba09aeedfb078afc4711973437eb5a38e6279
7
+ data.tar.gz: 2d3438c23a8146367289413e28d756eee5cb303c58c5820273f471482a83f351d0fc379a3db01a6edbbcdd5f13afbce508b8f999344d0e295dfb6fd74c816f88
data/RELEASE.md CHANGED
@@ -7,6 +7,29 @@ Release-note scope rule:
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 4.3.0
11
+
12
+ ### New
13
+
14
+ - **Local-centred workstyle** — Carson now supports two workstyles. Local-centred (the new default) merges parcels directly into the local vault (main) and pushes to the remote as backup. No PR, no CI gate, no waiting. Remote-centred remains available for projects that need Bureau oversight. Set `workstyle: remote` in `.carson.yml` to use the old behaviour.
15
+ - **`Warehouse::Vault` concern** — the vault is local main, where accepted parcels live. `warehouse.accept!( parcel )` merges a branch into main via fast-forward. If the branch has diverged, the agent must rebase first.
16
+ - **`warehouse.prepare!( parcel, message: )`** — the delivery prep phase: pack, fetch latest standard, check freshness, auto-rebase if behind. No compliance gate in local workstyle — agents handle linting and testing themselves.
17
+
18
+ ### Changed
19
+
20
+ - **Courier is workstyle-aware** — `courier.deliver( parcel )` uses different gestures per workstyle. Local: push main to backup vault. Remote: the existing Bureau trip (unchanged). One verb, two gestures. The Courier doesn't know if it's doing "backup" or "primary" — it just delivers.
21
+ - **CLI orchestrates local delivery** — `carson deliver` in local workstyle follows the checkin/checkout pattern: CLI builds Warehouse + Courier, orchestrates `prepare!` → `accept!` → `courier.deliver`. No Runtime in the local path.
22
+
23
+ ### UX
24
+
25
+ - `carson deliver` output (local workstyle): `⧓ <branch> merged into main.` / `⧓ Pushed to remote.`
26
+ - On failure: clear recovery instructions (`Rebase onto main and deliver again.`)
27
+ - On backup failure: `⧓ Backup failed.` with recovery.
28
+
29
+ ### Why
30
+
31
+ The 2026-03-24 retrospective clarified that for a solo developer with agents, the remote-centred machinery (PR → CI → Bureau polling → merge) added latency, cost, and complexity without serving a need. The vault (local main) is the source of truth. GitHub is the backup vault. The Courier's job is simpler and faster.
32
+
10
33
  ## 4.2.1
11
34
 
12
35
  ### Fixed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.2.1
1
+ 4.3.0
data/lib/carson/config.rb CHANGED
@@ -33,7 +33,8 @@ module Carson
33
33
  :govern_repos, :govern_merge_method,
34
34
  :govern_agent_provider, :govern_state_path,
35
35
  :govern_check_wait,
36
- :poll_interval_at_bureau
36
+ :poll_interval_at_bureau,
37
+ :workstyle
37
38
 
38
39
  def self.load( repo_root: )
39
40
  base_data = default_data
@@ -85,6 +86,7 @@ module Carson
85
86
  "deliver" => {
86
87
  "poll_interval_at_bureau" => 30
87
88
  },
89
+ "workstyle" => "local",
88
90
  "govern" => {
89
91
  "repos" => [],
90
92
  "merge" => {
@@ -247,6 +249,9 @@ module Carson
247
249
  deliver_hash = fetch_hash( hash: data, key: "deliver" )
248
250
  @poll_interval_at_bureau = fetch_non_negative_integer( hash: deliver_hash, key: "poll_interval_at_bureau" )
249
251
 
252
+ workstyle_raw = data.fetch( "workstyle", "local" ).to_s.downcase
253
+ @workstyle = [ "local", "remote" ].include?( workstyle_raw ) ? workstyle_raw.to_sym : :local
254
+
250
255
  govern_hash = fetch_hash( hash: data, key: "govern" )
251
256
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
252
257
  govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
@@ -1,6 +1,8 @@
1
1
  # Carson Co.
2
+ require "open3"
3
+
2
4
  module Carson
3
- # The delivery person — picks up parcels and delivers them to the bureau.
5
+ # The delivery workerwaits at the gate, picks up parcels, delivers them.
4
6
  #
5
7
  # The courier is a Carson employee assigned to a warehouse. They pick up
6
8
  # a parcel, ask the warehouse to ship it, file a waybill, and wait at
@@ -59,11 +61,14 @@ module Carson
59
61
  # answer, the courier reports "filed" — the parcel is still at the bureau
60
62
  # and the shelf stays sealed.
61
63
  #
62
- # == Future: destination modes
64
+ # == Workstyle
65
+ #
66
+ # The courier's gesture depends on the workstyle:
67
+ # - :local — push main to backup vault (simple, no PR, no waiting)
68
+ # - :remote — ship → waybill → bureau → acceptance (complex Bureau trip)
63
69
  #
64
- # Currently remote-centred (ship waybill bureau acceptance).
65
- # A future local-centred mode merges locally; remote is a synced backup.
66
- # The destination mode should be injectable, not baked in.
70
+ # The courier doesn't know whether it's doing "backup" or "primary" —
71
+ # it just delivers to wherever the workstyle dictates.
67
72
  class Courier
68
73
  # Exit codes — shared contract between Carson employees and the CLI.
69
74
  OK = 0
@@ -75,17 +80,21 @@ module Carson
75
80
  # The courier checks the bureau up to 6 times before leaving.
76
81
  MAX_CHECKS_AT_BUREAU = 6
77
82
 
78
- def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_bureau: 30, output: $stdout )
83
+ def initialize( warehouse, workstyle: :local, ledger: nil, merge_method: "rebase", poll_interval_at_bureau: 30, output: $stdout )
79
84
  @warehouse = warehouse
85
+ @workstyle = workstyle
80
86
  @ledger = ledger
81
87
  @merge_method = merge_method
82
88
  @poll_interval_at_bureau = poll_interval_at_bureau
83
89
  @output = output
84
90
  end
85
91
 
86
- # Deliver a parcel to the registry.
87
- # Ships it, files a waybill, seals the shelf, waits at the bureau.
92
+ # Deliver a parcel.
93
+ # Local gesture: push main to backup vault.
94
+ # Remote gesture: ship to Bureau, file waybill, poll, register.
88
95
  def deliver( parcel, title: nil, body_file: nil, commit_message: nil )
96
+ return deliver_locally( parcel ) if @workstyle == :local
97
+
89
98
  result = {
90
99
  command: "deliver",
91
100
  label: parcel.label,
@@ -246,6 +255,41 @@ module Carson
246
255
  result[ :diagnostic ] = waybill.ci_diagnostic
247
256
  end
248
257
 
258
+ # Local gesture: push main to the backup vault.
259
+ # The parcel is already in the vault (accepted by the Warehouse).
260
+ # The courier's job is to push the vault state to the remote backup.
261
+ def deliver_locally( parcel )
262
+ result = {
263
+ command: "deliver",
264
+ label: parcel.label,
265
+ remote_main: "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
266
+ }
267
+
268
+ remote = @warehouse.bureau_address
269
+ main = @warehouse.main_label
270
+ root = @warehouse.main_worktree_root
271
+
272
+ # --no-verify bypasses the pre-push hook that blocks direct pushes
273
+ # to main in governed repos. This IS Carson's delivery — the vault
274
+ # accepted the parcel, now the courier backs it up.
275
+ _, stderr, status = Open3.capture3(
276
+ "git", "-C", root, "push", "--no-verify", remote, main
277
+ )
278
+
279
+ if status.success?
280
+ result[ :exit ] = OK
281
+ result[ :outcome ] = "delivered"
282
+ result[ :synced ] = true
283
+ else
284
+ result[ :exit ] = OK
285
+ result[ :outcome ] = "delivered"
286
+ result[ :synced ] = false
287
+ result[ :backup_error ] = stderr.strip
288
+ end
289
+
290
+ result
291
+ end
292
+
249
293
  # Is the waybill blocked by something that won't resolve by waiting?
250
294
  # CI failure, merge conflict, policy block — the courier should take
251
295
  # the parcel back immediately.
@@ -20,6 +20,7 @@ module Carson
20
20
  head: current_head
21
21
  )
22
22
  courier = Courier.new( warehouse,
23
+ workstyle: :remote,
23
24
  ledger: ledger,
24
25
  merge_method: config.govern_merge_method,
25
26
  poll_interval_at_bureau: config.poll_interval_at_bureau,
@@ -0,0 +1,76 @@
1
+ # The warehouse's vault concern.
2
+ # The vault is local main — where accepted parcels live.
3
+ # In local-centred workstyle, the vault is the source of truth.
4
+ # In remote-centred workstyle, the vault is the backup (receives
5
+ # the standard from the bureau's registry after acceptance).
6
+ require "open3"
7
+
8
+ module Carson
9
+ class Warehouse
10
+ module Vault
11
+
12
+ # Accept a parcel into the vault.
13
+ # Fast-forwards local main to include the parcel's branch.
14
+ # Runs from the main worktree root where main is checked out.
15
+ #
16
+ # Precondition: the parcel's branch must be a fast-forward of main.
17
+ # If not, the agent must rebase first.
18
+ #
19
+ # Returns a result hash:
20
+ # { status: "ok", branch: ..., head: ... }
21
+ # { status: "block", error: ..., recovery: ... }
22
+ # { status: "error", error: ..., recovery: ... }
23
+ def accept!( parcel )
24
+ root = main_worktree_root
25
+
26
+ # Verify main is checked out in the main worktree.
27
+ unless main_checked_out_at?( root )
28
+ return {
29
+ status: "error",
30
+ error: "#{@main_label} is not checked out in the main worktree.",
31
+ recovery: "Check the main worktree state at #{root}."
32
+ }
33
+ end
34
+
35
+ # Fast-forward main to include the parcel's branch.
36
+ _, stderr, status = Open3.capture3(
37
+ "git", "-C", root, "merge", "--ff-only", parcel.label
38
+ )
39
+
40
+ return vault_accepted( parcel, root ) if status.success?
41
+
42
+ vault_blocked( parcel, stderr )
43
+ end
44
+
45
+ private
46
+
47
+ # Check whether main is the checked-out branch at a given path.
48
+ def main_checked_out_at?( root )
49
+ head_ref, _, status = Open3.capture3(
50
+ "git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"
51
+ )
52
+ status.success? && head_ref.strip == @main_label
53
+ end
54
+
55
+ # Build the success result after vault acceptance.
56
+ def vault_accepted( parcel, root )
57
+ new_head, = Open3.capture3( "git", "-C", root, "rev-parse", "HEAD" )
58
+ {
59
+ status: "ok",
60
+ branch: parcel.label,
61
+ head: new_head.strip
62
+ }
63
+ end
64
+
65
+ # Build the blocked/error result when vault acceptance fails.
66
+ def vault_blocked( parcel, stderr )
67
+ {
68
+ status: "block",
69
+ error: "#{parcel.label} cannot be fast-forwarded into #{@main_label}.",
70
+ recovery: "Rebase onto #{@main_label} and deliver again."
71
+ }
72
+ end
73
+
74
+ end
75
+ end
76
+ end
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "open3"
7
7
 
8
8
  require_relative "warehouse/workbench"
9
+ require_relative "warehouse/vault"
9
10
  require_relative "warehouse/seal"
10
11
  require_relative "warehouse/bureau"
11
12
 
@@ -16,6 +17,7 @@ module Carson
16
17
  # managing workbenches, and sweeping up.
17
18
  class Warehouse
18
19
  include Workbench
20
+ include Vault
19
21
  include Seal
20
22
  include Bureau
21
23
 
@@ -140,6 +142,49 @@ module Carson
140
142
  end
141
143
  end
142
144
 
145
+ # --- Delivery prep ---
146
+
147
+ # Prepare a parcel for delivery.
148
+ # Orchestrates the prep phase: pack, fetch, standard check, auto-rebase.
149
+ # Returns { status: "ok" } or { status: "block"/"error", error:, recovery: }.
150
+ def prepare!( parcel, message: nil )
151
+ registry = "#{bureau_address}/#{main_label}"
152
+
153
+ # Pack if the agent provided a commit message.
154
+ if message
155
+ unless pack!( message: message )
156
+ return { status: "error", error: "Nothing to commit.", recovery: "Stage changes first." }
157
+ end
158
+ # Update the parcel's head after packing.
159
+ parcel = Parcel.new( label: parcel.label, head: current_head )
160
+ end
161
+
162
+ # Fetch the latest standard.
163
+ unless fetch_latest
164
+ return {
165
+ status: "block",
166
+ error: "Cannot fetch latest standard.",
167
+ recovery: "Check network and remote config, then deliver again."
168
+ }
169
+ end
170
+
171
+ # Check if the parcel is based on the latest standard.
172
+ unless based_on_latest_standard?( parcel, registry: registry )
173
+ # Auto-rebase onto the latest standard.
174
+ unless rebase_on_latest_standard!( registry: registry )
175
+ return {
176
+ status: "block",
177
+ error: "#{parcel.label} conflicts with #{@main_label}.",
178
+ recovery: "Rebase onto #{@main_label}, resolve conflicts, deliver again."
179
+ }
180
+ end
181
+ # Update the parcel's head after rebase.
182
+ parcel = Parcel.new( label: parcel.label, head: current_head )
183
+ end
184
+
185
+ { status: "ok", parcel: parcel }
186
+ end
187
+
143
188
  # --- Inventory ---
144
189
 
145
190
  # All labels (branch names).
data/lib/cli.rb CHANGED
@@ -1023,12 +1023,16 @@ module Carson
1023
1023
  when "template:apply"
1024
1024
  runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
1025
1025
  when "deliver"
1026
- runtime.deliver!(
1027
- title: parsed.fetch( :title, nil ),
1028
- body_file: parsed.fetch( :body_file, nil ),
1029
- commit_message: parsed.fetch( :commit_message, nil ),
1030
- json_output: parsed.fetch( :json, false )
1031
- )
1026
+ if runtime.config.workstyle == :local
1027
+ dispatch_deliver_locally( parsed: parsed, runtime: runtime )
1028
+ else
1029
+ runtime.deliver!(
1030
+ title: parsed.fetch( :title, nil ),
1031
+ body_file: parsed.fetch( :body_file, nil ),
1032
+ commit_message: parsed.fetch( :commit_message, nil ),
1033
+ json_output: parsed.fetch( :json, false )
1034
+ )
1035
+ end
1032
1036
  when "recover"
1033
1037
  runtime.recover!(
1034
1038
  check_name: parsed.fetch( :check_name ),
@@ -1081,6 +1085,81 @@ module Carson
1081
1085
  report_workbench( result: result, json: parsed.fetch( :json, false ), output: runtime.output )
1082
1086
  end
1083
1087
 
1088
+ # --- Local-centred delivery ---
1089
+ # CLI orchestrates: prepare → accept → courier.deliver.
1090
+ # No Runtime — follows the checkin/checkout pattern.
1091
+
1092
+ def self.dispatch_deliver_locally( parsed:, runtime: )
1093
+ # The warehouse must be at the current worktree (where the agent works),
1094
+ # not the main worktree root. The agent's branch and head live here.
1095
+ warehouse = Warehouse.new(
1096
+ path: runtime.send( :work_dir ),
1097
+ main_label: runtime.config.main_branch,
1098
+ bureau_address: runtime.config.git_remote
1099
+ )
1100
+ parcel = Parcel.new( label: warehouse.current_label, head: warehouse.current_head )
1101
+ message = parsed.fetch( :commit_message, nil )
1102
+ json = parsed.fetch( :json, false )
1103
+ output = runtime.output
1104
+
1105
+ # Step 1: Prepare.
1106
+ prep = warehouse.prepare!( parcel, message: message )
1107
+ unless prep[ :status ] == "ok"
1108
+ prep[ :command ] = "deliver"
1109
+ return report_deliver( result: prep, json: json, output: output )
1110
+ end
1111
+
1112
+ # Update parcel if prepare rebased or packed.
1113
+ parcel = prep[ :parcel ] || parcel
1114
+
1115
+ # Step 2: Accept into vault.
1116
+ accept = warehouse.accept!( parcel )
1117
+ accept[ :command ] = "deliver"
1118
+ unless accept[ :status ] == "ok"
1119
+ return report_deliver( result: accept, json: json, output: output )
1120
+ end
1121
+
1122
+ # Step 3: Courier delivers backup.
1123
+ courier = Courier.new( warehouse, workstyle: :local, output: output )
1124
+ backup = courier.deliver( parcel )
1125
+
1126
+ # Combine results.
1127
+ result = accept.merge( backup.slice( :outcome, :synced, :backup_error ) )
1128
+ result[ :command ] = "deliver"
1129
+ result[ :outcome ] ||= "delivered"
1130
+ report_deliver( result: result, json: json, output: output )
1131
+ end
1132
+
1133
+ # Render a local delivery result.
1134
+ def self.report_deliver( result:, json:, output: )
1135
+ status = result[ :status ]
1136
+ exit_code = case status
1137
+ when "ok" then Runtime::EXIT_OK
1138
+ when "block" then Runtime::EXIT_BLOCK
1139
+ else Runtime::EXIT_ERROR
1140
+ end
1141
+
1142
+ if json
1143
+ output.puts JSON.pretty_generate( result )
1144
+ else
1145
+ case status
1146
+ when "ok"
1147
+ output.puts "#{BADGE} #{result[ :branch ]} merged into main."
1148
+ if result[ :synced ]
1149
+ output.puts "#{BADGE} Pushed to #{result.fetch( :remote_main, "remote" )}."
1150
+ elsif result[ :backup_error ]
1151
+ output.puts "#{BADGE} Backup failed."
1152
+ output.puts " \u2192 git push"
1153
+ end
1154
+ when "block", "error"
1155
+ output.puts "#{BADGE} #{result[ :error ]}"
1156
+ output.puts " \u2192 #{result[ :recovery ]}" if result[ :recovery ]
1157
+ end
1158
+ end
1159
+
1160
+ exit_code
1161
+ end
1162
+
1084
1163
  # Build a Warehouse rooted at the main worktree.
1085
1164
  # Uses runtime's resolved root and config for remote/branch names.
1086
1165
  def self.build_warehouse( runtime: )
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: 4.2.1
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -84,6 +84,7 @@ files:
84
84
  - lib/carson/warehouse.rb
85
85
  - lib/carson/warehouse/bureau.rb
86
86
  - lib/carson/warehouse/seal.rb
87
+ - lib/carson/warehouse/vault.rb
87
88
  - lib/carson/warehouse/workbench.rb
88
89
  - lib/carson/waybill.rb
89
90
  - lib/carson/worktree.rb