carson 4.0.0 → 4.0.2

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: 96b7009510f859ab0892bffc34678701dc74412a4f4b9f17f1be3710cb82d0c3
4
- data.tar.gz: 236de5bb54d69845b4710b8ffe946a64534a1db5ea068e2a8e34d566d6c438c4
3
+ metadata.gz: 8bf071658d6db37302910554b54a528ac3efe7faa22a1cb88b42d13faf0b6b30
4
+ data.tar.gz: 0e30d7ca60c8d8f13438562b003009a64820ff7109ca1c525a127282a5c942c9
5
5
  SHA512:
6
- metadata.gz: 334f80f636c8285fbb7e3bb4481b99efecf0b69700b337ad03a63cc1b9fb0fe0d1ab49765371aa57ede881958cb0d2d9f9cff9104b50d007352edd94fb1cd22e
7
- data.tar.gz: b6a92f602a9d0fdd9b22ba320616c6fe2db66d51230895b88e32962edaa5c43cfcadfe828278afbeb4111585b10d04781708c2d17dcdb03df10320fa1674e6c9
6
+ metadata.gz: 0d50b557c6e2a29b78d4f82dfedf2f89c4d6eacc68fb561d5684eb7c40582ba43de5ff84b6929c992fd98b5069e1ee15b17fb434f4512b3ea30f94c97a387ae6
7
+ data.tar.gz: 4cc5c13c062c226ffcfd8627882ecb6c3093169e61dc759d03e2b77110f93323b77bc90761107a8e2a7696b211fe42c603ad0823639c3eb062ff66753410f96b
data/RELEASE.md CHANGED
@@ -10,6 +10,27 @@ Release-note scope rule:
10
10
  ### Breaking
11
11
 
12
12
  - CLI grammar is now two-tier: portfolio commands (`list`, `onboard`, `offboard`, `refresh`, `version`) and repo-scoped commands (`carson <repo> <command>` or `carson <command>` from CWD)
13
+
14
+ ## 4.0.2
15
+
16
+ ### Fixed
17
+
18
+ - **Seal marker moved outside worktree** — `.carson-delivering` marker now lives at `~/.carson/seals/` instead of inside the worktree. The in-worktree marker polluted `git status`, showed as untracked in editors, and blocked `carson deliver` via the dirty-tree guard. Seal file includes the worktree path for debugging.
19
+ - **`receive_latest_standard!` works from worktrees** (#451) — `git fetch origin main:main` failed when `main` was checked out in the main worktree ("fatal: refusing to fetch into branch checked out"). Now uses `merge --ff-only` when main is checked out, fetch refspec otherwise.
20
+ - **Audit seal check delegates to Warehouse** — `carson audit` no longer hardcodes the marker path; it creates a Warehouse and asks `sealed?`. Single source of truth for seal location.
21
+
22
+ ## 4.0.1
23
+
24
+ ### What changed
25
+
26
+ - **Workbench seal** — `carson deliver` seals the workbench after shipping. `pack!` refuses while sealed ("Shelf is sealed — parcel in flight"). `carson audit` blocks `git commit` on sealed workbenches. Delivered/held/rejected unseals; filed stays sealed.
27
+ - **Delivery progress** — `carson deliver` announces what it's doing: "Carson is delivering committed changes on branch X to Y…" and reports each poll check with count.
28
+ - **Seal guard in `carson audit`** — moved from bash hook template to Ruby. The pre-commit hook calls `carson audit` which checks for `.carson-delivering` marker.
29
+ - **`error_at_registry` is transient** — CI assessment errors no longer cause immediate rejection. The courier keeps polling.
30
+
31
+ ### Known limitation
32
+
33
+ - The workbench seal blocks `git commit` but does not block file edits (Write/Edit tools). That requires Claude Code PreToolUse hooks — a separate enforcement layer.
13
34
  - `govern` renamed to `receive` — single-repo only, no portfolio iteration
14
35
  - `repos` renamed to `list`
15
36
  - `--all` removed from all repo commands; use `carson list --json` to script batch operations
@@ -19,15 +40,14 @@ Release-note scope rule:
19
40
 
20
41
  ### Breaking
21
42
 
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.
43
+ - **`carson deliver` waits for CI and merges automatically** — `carson deliver` now polls GitHub up to 6 times (default 30s interval) instead of checking once and returning immediately. PRs that pass CI are merged without re-running `carson deliver`. Agents that relied on instant return should expect it to block for up to 3 minutes.
23
44
  - **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.
45
+ - **Hold messages include recovery commands** — Human output for held deliveries now shows actionable next steps (e.g. `→ git rebase origin/main` then `→ carson deliver`) instead of prose descriptions.
26
46
 
27
47
  ### New
28
48
 
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`.
49
+ - **`deliver.poll_interval_at_registry` config** — Controls how long `carson deliver` waits between CI checks (default 30 seconds). Override via config or `CARSON_POLL_INTERVAL_AT_REGISTRY` environment variable.
50
+ - **`filed` outcome** — When `carson deliver` exhausts all checks without a definitive answer from GitHub, it reports "filed" with `→ carson status` as the next step, instead of the old "deferred" with `→ carson deliver`.
31
51
 
32
52
  ### Migration
33
53
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.0.0
1
+ 4.0.2
@@ -15,6 +15,14 @@ module Carson
15
15
  # parcels (CI, review, mergeability) and either accept them into the
16
16
  # registry or hold them with a reason.
17
17
  #
18
+ # == Shelf seal
19
+ #
20
+ # Once the parcel ships and the waybill is filed, the warehouse seals
21
+ # the shelf. No more packing until the delivery outcome is confirmed.
22
+ # Delivered → shelf done (housekeep removes it).
23
+ # Held/rejected → courier unseals (agent can fix and re-deliver).
24
+ # Filed (checks exhausted) → shelf stays sealed (parcel still in flight).
25
+ #
18
26
  # == Situations the courier encounters
19
27
  #
20
28
  # Each numbered situation is handled by a specific guard or branch in the
@@ -45,7 +53,8 @@ module Carson
45
53
  # It polls up to MAX_CHECKS_AT_REGISTRY times, pausing between each check.
46
54
  # If the bureaucrats give a definitive answer (accepted or rejected), the
47
55
  # courier acts immediately. If the checks are exhausted without a definitive
48
- # answer, the courier reports "filed" — the parcel is still at the registry.
56
+ # answer, the courier reports "filed" — the parcel is still at the registry
57
+ # and the shelf stays sealed.
49
58
  #
50
59
  # == Future: destination modes
51
60
  #
@@ -58,18 +67,21 @@ module Carson
58
67
  ERROR = 1
59
68
  BLOCKED = 2
60
69
 
70
+ BADGE = "\u29D3".freeze
71
+
61
72
  # The courier checks the registry up to 6 times before leaving.
62
73
  MAX_CHECKS_AT_REGISTRY = 6
63
74
 
64
- def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_registry: 30 )
75
+ def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_registry: 30, output: $stdout )
65
76
  @warehouse = warehouse
66
77
  @ledger = ledger
67
78
  @merge_method = merge_method
68
79
  @poll_interval_at_registry = poll_interval_at_registry
80
+ @output = output
69
81
  end
70
82
 
71
83
  # Deliver a parcel to the registry.
72
- # Ships it, files a waybill, waits at the registry for the bureaucrats.
84
+ # Ships it, files a waybill, seals the shelf, waits at the registry.
73
85
  def deliver( parcel, title: nil, body_file: nil, commit_message: nil )
74
86
  result = {
75
87
  command: "deliver",
@@ -121,6 +133,9 @@ module Carson
121
133
  recovery: "git rebase #{remote_main}, then carson deliver" )
122
134
  end
123
135
 
136
+ # Announce the delivery.
137
+ say "Carson is delivering committed changes on branch #{parcel.label} to #{result[ :remote_main ]}..."
138
+
124
139
  # The courier picks up the parcel — start tracking.
125
140
  record( parcel, status: "preparing", summary: "delivery accepted" )
126
141
 
@@ -144,11 +159,20 @@ module Carson
144
159
  result[ :tracking_number ] = waybill.tracking_number
145
160
  result[ :url ] = waybill.url
146
161
 
162
+ # Seal the shelf — no more packing until the outcome is confirmed.
163
+ @warehouse.seal_shelf!( tracking_number: waybill.tracking_number )
164
+
147
165
  # Wait at the registry while the bureaucrats check the parcel.
148
166
  wait_and_poll_at_registry( waybill, result )
149
167
 
168
+ # Unseal based on outcome:
169
+ # delivered/held/rejected → unseal (shelf done or parcel returned)
170
+ # filed → stay sealed (parcel still in flight)
171
+ outcome = result[ :outcome ]
172
+ @warehouse.unseal_shelf! if outcome == "delivered" || outcome == "held" || outcome == "rejected"
173
+
150
174
  # Update the ledger with the final outcome.
151
- record( parcel, status: result[ :outcome ] || "filed", summary: result[ :hold_reason ] )
175
+ record( parcel, status: outcome || "filed", summary: result[ :hold_reason ] )
152
176
 
153
177
  result[ :exit ] ||= OK
154
178
  result
@@ -196,6 +220,9 @@ module Carson
196
220
  return
197
221
  end
198
222
 
223
+ # Report progress — the courier tells what the bureaucrats said.
224
+ say "#{waybill.hold_summary} (#{check + 1}/#{MAX_CHECKS_AT_REGISTRY})..."
225
+
199
226
  # Still waiting — pause before the next check.
200
227
  pause_between_polls unless check == MAX_CHECKS_AT_REGISTRY - 1
201
228
  end
@@ -215,6 +242,11 @@ module Carson
215
242
  "behind_registry", "policy_block", "draft" ].include?( reason )
216
243
  end
217
244
 
245
+ # The courier speaks — reports progress to whoever is listening.
246
+ def say( message )
247
+ @output&.puts "#{BADGE} #{message}"
248
+ end
249
+
218
250
  # Pause between poll checks. Overridable for test isolation.
219
251
  def pause_between_polls
220
252
  sleep @poll_interval_at_registry
@@ -7,6 +7,12 @@ module Carson
7
7
  class Runtime
8
8
  module Audit
9
9
  def audit!( json_output: false )
10
+ # Sealed workbench guard — hard block before anything else.
11
+ # The warehouse seals the workbench when a parcel ships.
12
+ # No commits allowed until the delivery outcome is confirmed.
13
+ sealed_result = audit_sealed_workbench( json_output: json_output )
14
+ return sealed_result unless sealed_result.nil?
15
+
10
16
  fingerprint_status = block_if_outsider_fingerprints!
11
17
  return fingerprint_status unless fingerprint_status.nil?
12
18
  unless head_exists?
@@ -177,6 +183,31 @@ module Carson
177
183
  end
178
184
  end
179
185
 
186
+ # Check if the workbench is sealed (parcel in flight).
187
+ # Returns EXIT_BLOCK if sealed, nil otherwise.
188
+ # Delegates to Warehouse so the seal path is resolved in one place.
189
+ def audit_sealed_workbench( json_output: )
190
+ warehouse = Warehouse.new( path: work_dir )
191
+ return nil unless warehouse.sealed?
192
+
193
+ tracking_number = warehouse.sealed_tracking_number || "unknown"
194
+ if json_output
195
+ require "json"
196
+ output.puts JSON.pretty_generate( {
197
+ command: "audit",
198
+ status: "block",
199
+ reason: "workbench_sealed",
200
+ tracking_number: tracking_number,
201
+ recovery: "carson worktree create <name>",
202
+ exit_code: EXIT_BLOCK
203
+ } )
204
+ else
205
+ puts_line "Workbench is sealed — parcel in flight (PR ##{tracking_number})."
206
+ puts_line " \u2192 carson worktree create <name>"
207
+ end
208
+ EXIT_BLOCK
209
+ end
210
+
180
211
  def audit_working_tree_report
181
212
  dirty_reason = dirty_worktree_reason
182
213
  return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
@@ -22,7 +22,8 @@ module Carson
22
22
  courier = Courier.new( warehouse,
23
23
  ledger: ledger,
24
24
  merge_method: config.govern_merge_method,
25
- poll_interval_at_registry: config.poll_interval_at_registry
25
+ poll_interval_at_registry: config.poll_interval_at_registry,
26
+ output: output
26
27
  )
27
28
 
28
29
  result = courier.deliver( parcel,
@@ -2,6 +2,8 @@
2
2
  # parcels (committed changes) are stored on shelves (worktrees) with
3
3
  # labels (branches). Git commands are hidden inside — callers never
4
4
  # see git terms.
5
+ require "digest"
6
+ require "fileutils"
5
7
  require "open3"
6
8
 
7
9
  module Carson
@@ -91,22 +93,81 @@ module Carson
91
93
  end
92
94
 
93
95
  # Pack a parcel — stage all changes and commit.
96
+ # Refuses if the shelf is sealed (parcel already in flight).
94
97
  # Returns true on success, false on failure.
95
98
  def pack!( message: )
99
+ if sealed?
100
+ raise "Shelf is sealed — parcel in flight (PR ##{sealed_tracking_number}). " \
101
+ "Create a new worktree to continue working."
102
+ end
96
103
  git( "add", "-A" )
97
104
  _, _, status = git( "commit", "-m", message )
98
105
  status.success?
99
106
  end
100
107
 
108
+ # --- Shelf seal ---
109
+
110
+ # Seal the shelf — no more packing until the delivery outcome is confirmed.
111
+ # The courier seals the shelf after shipping and filing the waybill.
112
+ # The marker lives outside the worktree (~/.carson/seals/) so it does
113
+ # not pollute git status or block delivery with a dirty-tree guard.
114
+ def seal_shelf!( tracking_number: )
115
+ marker = delivering_marker_path
116
+ FileUtils.mkdir_p( File.dirname( marker ) )
117
+ File.write( marker, "#{tracking_number}\n#{@path}" )
118
+ end
119
+
120
+ # Unseal the shelf — the courier brought back the parcel.
121
+ # Called when the delivery outcome is held or rejected.
122
+ def unseal_shelf!
123
+ File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
124
+ end
125
+
126
+ # Is this shelf sealed for a delivery in flight?
127
+ def sealed?
128
+ File.exist?( delivering_marker_path )
129
+ end
130
+
131
+ # The tracking number of the in-flight delivery (nil if not sealed).
132
+ def sealed_tracking_number
133
+ return nil unless sealed?
134
+ File.read( delivering_marker_path ).lines.first.strip
135
+ end
136
+
101
137
  # Receive the latest standard from the registry after a parcel is accepted.
102
138
  # Fast-forwards local main without switching branches.
103
139
  # Returns true on success, false on failure.
140
+ #
141
+ # Two paths depending on the main worktree's checkout state:
142
+ # - Main checked out → merge --ff-only (updates ref + working tree).
143
+ # - Main not checked out → fetch refspec (updates ref only, safe when
144
+ # no worktree has the branch).
104
145
  def receive_latest_standard!( remote: bureau_address )
105
- _, _, status = Open3.capture3(
106
- "git", "-C", main_worktree_root,
107
- "fetch", remote, "#{main_label}:#{main_label}"
146
+ root = main_worktree_root
147
+
148
+ # Fetch remote tracking refs — always safe, even when main is checked out.
149
+ _, _, fetch_status = Open3.capture3( "git", "-C", root, "fetch", remote )
150
+ return false unless fetch_status.success?
151
+
152
+ # Determine how to advance local main.
153
+ head_ref, _, head_status = Open3.capture3(
154
+ "git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"
108
155
  )
109
- status.success?
156
+ return false unless head_status.success?
157
+
158
+ if head_ref.strip == @main_label
159
+ # Main is checked out in the main worktree — fast-forward via merge.
160
+ _, _, merge_status = Open3.capture3(
161
+ "git", "-C", root, "merge", "--ff-only", "#{remote}/#{@main_label}"
162
+ )
163
+ merge_status.success?
164
+ else
165
+ # Main is not checked out — safe to update the ref via fetch refspec.
166
+ _, _, refspec_status = Open3.capture3(
167
+ "git", "-C", root, "fetch", remote, "#{@main_label}:#{@main_label}"
168
+ )
169
+ refspec_status.success?
170
+ end
110
171
  end
111
172
 
112
173
  # --- Inventory ---
@@ -142,6 +203,15 @@ module Carson
142
203
 
143
204
  private
144
205
 
206
+ # Path to the delivery marker file — signals the shelf is sealed.
207
+ # Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
208
+ # so it does not pollute git status.
209
+ def delivering_marker_path
210
+ seals_dir = File.join( Dir.home, ".carson", "seals" )
211
+ key = Digest::SHA256.hexdigest( @path )
212
+ File.join( seals_dir, key )
213
+ end
214
+
145
215
  # All git commands go through this single gateway.
146
216
  # Returns [stdout, stderr, status].
147
217
  def git( *arguments )
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.0.0
4
+ version: 4.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang