carson 4.0.3 → 4.1.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: a5678a9b3d25a0a0c6e2b37182e53607b8c41e0a199771bbae7535ec776f71c8
4
- data.tar.gz: c744e380475f851bdc142dc13693f3b35a229b93a45fdbc2ecb70e33fd3ba8cc
3
+ metadata.gz: 4f0dff052fa48cda5c1c0819ff109a9e93c70579c3e450d8af3e82bb55140b54
4
+ data.tar.gz: b2543300a607dccd88aaa3286e3c37b34cb4e847a30b3fb202ee76cc223125fb
5
5
  SHA512:
6
- metadata.gz: 136aeaa5ebf42de0f676875a44d8d4bf2eb758b9d70753718ba6cb27ca37756ca42af3518247e1a357c6e5a3bb8cdaa9bbdeff2d1fa9f3fd63302263b4d3c9a1
7
- data.tar.gz: db5026a393f292c7dee8e1a6b3d63ec4463bcaebea179161ceb184271826d0c1a803bfc9a6a31f6cc5d7446f10be3e52739bebfbb70c548a8cb607fd1f57e4ae
6
+ metadata.gz: c0b1853fe814ad941a983d4d0676eb1912ec818b3387b3d74ab61241cb413bea296a797b6bb2f7fbbca8b30f978fc6df203a27a1879727bd753c3a615efdf8ec
7
+ data.tar.gz: 3a51ccae965f6f0008123396c0347a3758c8ad28fac60ceabf8a058bb44538afd2988155b9ec895fe0419f0ddf82279c5f86517f2626dec5dfe96b2375877153
data/RELEASE.md CHANGED
@@ -7,9 +7,28 @@ Release-note scope rule:
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 4.1.0
11
+
10
12
  ### Breaking
11
13
 
12
14
  - 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)
15
+ - Config key `poll_interval_at_registry` renamed to `poll_interval_at_bureau`. Env var `CARSON_POLL_INTERVAL_AT_REGISTRY` renamed to `CARSON_POLL_INTERVAL_AT_BUREAU`.
16
+ - Hold reason codes renamed: `*_at_registry` → `*_at_bureau`, `behind_registry` → `behind_bureau`. Affects JSON output consumers matching on these strings.
17
+ - `Carson.translate_hold` removed. Recovery steps now come from `Carson.recovery_steps_for_hold` (private).
18
+ - `Carson.report` format `:human` renamed to `:text`.
19
+
20
+ ### What changed
21
+
22
+ - **Waybill is a data object** — `Waybill` no longer calls `gh`. It records findings written onto it by the Warehouse and answers questions about its state. `fetch_ci`, `refresh!`, `accept!`, `file!` removed. Added `record()`, `stamp()`, `ci_diagnostic`.
23
+ - **Warehouse owns bureau interaction** — `check_parcel_at_bureau_with()`, `file_waybill_for!()`, `register_parcel_at_bureau_with!()` added. The Warehouse queries GitHub and writes findings onto the Waybill. Diagnostic context (stderr from `gh pr checks`) is captured and surfaced.
24
+ - **CI diagnostic surfaces in output** (#468) — When CI checks cannot be assessed, the first line of `gh` stderr appears in polling messages and the delivery report. Agents can see WHY checks failed, not just that they failed.
25
+ - **No story language in CLI output** (#458) — `hold_summary` returns client language directly. Polling messages, delivery reports, seal messages, and housekeep output all use technical terms (CI checks, PR, branch, merge). Story language (bureau, bureaucrat, parcel, waybill) is confined to source code.
26
+ - **Consistent naming: bureau, not registry** — GitHub interactions use "bureau" consistently. "Registry" was a subset concept that leaked into method and constant names, creating confusion with "bureau."
27
+
28
+ ### Fixed
29
+
30
+ - **Repeated opaque CI error polling** (#468) — `fetch_ci` discarded `gh pr checks` stderr. Six retries showed "unable to reach the bureaucrats" with no cause. Now captures the first line of stderr and shows it: "Unable to assess CI checks. — HTTP 404: Not Found (1/6)..."
31
+ - **Story language in polling output** (#458) — Courier polling used `waybill.hold_summary` (story language) instead of client language. Now uses client-language summaries directly.
13
32
 
14
33
  ## 4.0.3
15
34
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.0.3
1
+ 4.1.0
@@ -96,6 +96,9 @@ on_main_branch=false
96
96
  delivery_pattern='(^|&&|\|\||;|\|)\s*gh\s+pr\s+(create|merge)\b'
97
97
  worktree_pattern='(^|&&|\|\||;|\|)\s*git\s+worktree\s+(add|remove)\b'
98
98
  pull_rebase_pattern='(^|&&|\|\||;|\|)\s*git\s+pull([^;&|]|\\\|)*--rebase\b'
99
+ fetch_pattern='(^|&&|\|\||;|\|)\s*git\s+fetch\b'
100
+ rebase_pattern='(^|&&|\|\||;|\|)\s*git\s+rebase\b'
101
+ rebase_continue_pattern='(^|&&|\|\||;|\|)\s*git\s+rebase\s+--(continue|abort|skip)\b'
99
102
  main_mutation_pattern='(^|&&|\|\||;|\|)\s*git\s+(add|commit)\b'
100
103
 
101
104
  if grep -qE "$delivery_pattern" <<<"$command_text"; then
@@ -116,6 +119,21 @@ if grep -qE "$pull_rebase_pattern" <<<"$command_text"; then
116
119
  "Use \`carson sync\` instead — it owns main-branch alignment in governed repos."
117
120
  fi
118
121
 
122
+ if grep -qE "$fetch_pattern" <<<"$command_text"; then
123
+ block_command \
124
+ "This repo is Carson-governed — do not use raw \`git fetch\`." \
125
+ "Carson commands handle fetching internally. Use \`carson deliver\` or \`carson sync\`."
126
+ fi
127
+
128
+ if grep -qE "$rebase_pattern" <<<"$command_text"; then
129
+ # Allow conflict resolution: --continue, --abort, --skip.
130
+ if ! grep -qE "$rebase_continue_pattern" <<<"$command_text"; then
131
+ block_command \
132
+ "This repo is Carson-governed — do not use raw \`git rebase\`." \
133
+ "\`carson deliver\` rebases automatically when the branch is behind."
134
+ fi
135
+ fi
136
+
119
137
  if [ "$on_main_worktree" = true ] && [ "$on_main_branch" = true ] && grep -qE "$main_mutation_pattern" <<<"$command_text"; then
120
138
  block_command \
121
139
  "Main working tree is read-only in this Carson-governed repo." \
@@ -9,7 +9,7 @@ module Carson
9
9
 
10
10
  def build_prompt( work_order: )
11
11
  parts = []
12
- parts << "You are an automated coding agent dispatched by Carson to fix an issue on a pull request."
12
+ parts << "You are an automated coding agent working on a pull request."
13
13
  parts << "Repository: #{sanitize( File.basename( work_order.repo ) )}"
14
14
  parts << "<pr_branch>#{sanitize( work_order.branch )}</pr_branch>"
15
15
  parts << "PR: ##{work_order.pr_number}"
data/lib/carson/cli.rb CHANGED
@@ -142,18 +142,10 @@ module Carson
142
142
  parser.separator ""
143
143
  parser.separator "Repository commands (from CWD or with explicit repo):"
144
144
  parser.separator " status Show repository delivery state"
145
- parser.separator " setup Initialise Carson configuration"
146
145
  parser.separator " audit Run pre-commit health checks"
147
- parser.separator " abandon Close and clean up abandoned delivery work"
148
- parser.separator " sync Sync local main with remote"
149
146
  parser.separator " deliver Start autonomous branch delivery"
150
147
  parser.separator " recover Merge the repair PR for one baseline-red governance check"
151
- parser.separator " prune Remove stale local branches"
152
148
  parser.separator " worktree Manage isolated coding worktrees"
153
- parser.separator " housekeep Sync, reap worktrees, and prune branches"
154
- parser.separator " review Manage PR review workflow"
155
- parser.separator " template Manage canonical template files"
156
- parser.separator " receive Triage and advance deliveries for one repo"
157
149
  parser.separator ""
158
150
  parser.separator "Run `carson <command> --help` for details on a specific command."
159
151
  end
@@ -478,16 +470,14 @@ module Carson
478
470
  def self.parse_worktree_subcommand( arguments:, error: )
479
471
  options = { json: false, force: false }
480
472
  worktree_parser = OptionParser.new do |parser|
481
- parser.banner = "Usage: carson worktree <create|list|remove> <name> [options]"
473
+ parser.banner = "Usage: carson worktree <create|list> <name> [options]"
482
474
  parser.separator ""
483
475
  parser.separator "Manage isolated worktrees for coding agents."
484
- parser.separator "Create auto-syncs main before branching. Remove guards against"
485
- parser.separator "unpushed commits and CWD-inside-worktree by default."
476
+ parser.separator "Create auto-syncs main before branching."
486
477
  parser.separator ""
487
478
  parser.separator "Subcommands:"
488
- parser.separator " create <name> Create a new worktree with a fresh branch"
489
- parser.separator " list List registered worktrees with cleanup status"
490
- parser.separator " remove <name> [--force] Remove a worktree (--force skips safety checks)"
479
+ parser.separator " create <name> Create a new worktree with a fresh branch"
480
+ parser.separator " list List registered worktrees with cleanup status"
491
481
  parser.separator ""
492
482
  parser.separator "Options:"
493
483
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
@@ -496,7 +486,6 @@ module Carson
496
486
  parser.separator "Examples:"
497
487
  parser.separator " carson worktree create feature-x Create an isolated worktree"
498
488
  parser.separator " carson worktree list Show registered worktrees"
499
- parser.separator " carson worktree remove feature-x Remove after work is pushed"
500
489
  end
501
490
  worktree_parser.parse!( arguments )
502
491
 
data/lib/carson/config.rb CHANGED
@@ -33,7 +33,7 @@ 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_registry
36
+ :poll_interval_at_bureau
37
37
 
38
38
  def self.load( repo_root: )
39
39
  base_data = default_data
@@ -83,7 +83,7 @@ module Carson
83
83
  "advisory_check_names" => [ "Scheduled review sweep", "Carson governance", "Tag, release, publish" ]
84
84
  },
85
85
  "deliver" => {
86
- "poll_interval_at_registry" => 30
86
+ "poll_interval_at_bureau" => 30
87
87
  },
88
88
  "govern" => {
89
89
  "repos" => [],
@@ -172,7 +172,7 @@ module Carson
172
172
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
173
173
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
174
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" ) )
175
+ deliver[ "poll_interval_at_bureau" ] = env_integer( key: "CARSON_POLL_INTERVAL_AT_BUREAU", fallback: deliver.fetch( "poll_interval_at_bureau" ) )
176
176
  govern = fetch_hash_section( data: copy, key: "govern" )
177
177
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
178
178
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
@@ -245,7 +245,7 @@ module Carson
245
245
  @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
246
246
 
247
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" )
248
+ @poll_interval_at_bureau = fetch_non_negative_integer( hash: deliver_hash, key: "poll_interval_at_bureau" )
249
249
 
250
250
  govern_hash = fetch_hash( hash: data, key: "govern" )
251
251
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
@@ -1,19 +1,22 @@
1
1
  # Carson Co.
2
2
  module Carson
3
- # The delivery person — picks up parcels and delivers them to the registry.
3
+ # The delivery person — picks up parcels and delivers them to the bureau.
4
4
  #
5
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.
6
+ # a parcel, ask the warehouse to ship it, file a waybill, and wait at
7
+ # the bureau while the bureaucrats check it.
8
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.
9
+ # The courier is a thin orchestrator: it asks the warehouse to interact
10
+ # with the bureau, reads the waybill for status, and reports results.
11
+ # The domain logic lives in the objects, not the courier.
11
12
  #
12
13
  # == The bureau
13
14
  #
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.
15
+ # The bureau (GitHub) is where bureaucrats work. They check parcels
16
+ # (CI, review, mergeability) and either accept them into the registry
17
+ # or hold them with a reason. The warehouse owns the connection to
18
+ # the bureau — the courier asks the warehouse to check, file, and
19
+ # register.
17
20
  #
18
21
  # == Shelf seal
19
22
  #
@@ -32,8 +35,8 @@ module Carson
32
35
  # 02. Parcel behind standard — not based on client's latest standard.
33
36
  # 03. Shipping fails — warehouse couldn't push to the bureau.
34
37
  # 04. Waybill filing fails — bureau rejected the paperwork.
35
- # 05. Pending at registry — bureaucrats still checking (CI running).
36
- # 06. Failed at registry — bureaucrats rejected (CI failed).
38
+ # 05. Pending at bureau — bureaucrats still checking (CI running).
39
+ # 06. Failed at bureau — bureaucrats rejected (CI failed).
37
40
  # 07. Review pending — review still in progress.
38
41
  # 08. Review changes requested — reviewer wants corrections.
39
42
  # 09. Merge conflict — parcel conflicts with registry contents.
@@ -47,18 +50,18 @@ module Carson
47
50
  # 17. Parcel already delivered — already in registry.
48
51
  # 18. Waybill closed — cancelled by someone externally.
49
52
  #
50
- # == Design: wait and poll at the registry
53
+ # == Design: wait and poll at the bureau
51
54
  #
52
- # The courier waits at the registry while the bureaucrats check the parcel.
53
- # It polls up to MAX_CHECKS_AT_REGISTRY times, pausing between each check.
55
+ # The courier waits at the bureau while the bureaucrats check the parcel.
56
+ # It polls up to MAX_CHECKS_AT_BUREAU times, pausing between each check.
54
57
  # If the bureaucrats give a definitive answer (accepted or rejected), the
55
58
  # courier acts immediately. If the checks are exhausted without a definitive
56
- # answer, the courier reports "filed" — the parcel is still at the registry
59
+ # answer, the courier reports "filed" — the parcel is still at the bureau
57
60
  # and the shelf stays sealed.
58
61
  #
59
62
  # == Future: destination modes
60
63
  #
61
- # Currently remote-centred (ship → waybill → registry → acceptance).
64
+ # Currently remote-centred (ship → waybill → bureau → acceptance).
62
65
  # A future local-centred mode merges locally; remote is a synced backup.
63
66
  # The destination mode should be injectable, not baked in.
64
67
  class Courier
@@ -69,19 +72,19 @@ module Carson
69
72
 
70
73
  BADGE = "\u29D3".freeze
71
74
 
72
- # The courier checks the registry up to 6 times before leaving.
73
- MAX_CHECKS_AT_REGISTRY = 6
75
+ # The courier checks the bureau up to 6 times before leaving.
76
+ MAX_CHECKS_AT_BUREAU = 6
74
77
 
75
- def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_registry: 30, output: $stdout )
78
+ def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_bureau: 30, output: $stdout )
76
79
  @warehouse = warehouse
77
80
  @ledger = ledger
78
81
  @merge_method = merge_method
79
- @poll_interval_at_registry = poll_interval_at_registry
82
+ @poll_interval_at_bureau = poll_interval_at_bureau
80
83
  @output = output
81
84
  end
82
85
 
83
86
  # Deliver a parcel to the registry.
84
- # Ships it, files a waybill, seals the shelf, waits at the registry.
87
+ # Ships it, files a waybill, seals the shelf, waits at the bureau.
85
88
  def deliver( parcel, title: nil, body_file: nil, commit_message: nil )
86
89
  result = {
87
90
  command: "deliver",
@@ -153,15 +156,11 @@ module Carson
153
156
  return error( result, "push failed" )
154
157
  end
155
158
 
156
- # File a waybill with the bureau.
157
- waybill = Waybill.new(
158
- label: parcel.label,
159
- warehouse_path: @warehouse.path
160
- )
161
- waybill.file!( title: title, body_file: body_file )
159
+ # File a waybill with the bureau — the warehouse handles the gh call.
160
+ waybill = @warehouse.file_waybill_for!( parcel, title: title, body_file: body_file )
162
161
 
163
162
  # 04. Waybill filing fails — bureau rejected the paperwork.
164
- unless waybill.filed?
163
+ unless waybill
165
164
  return error( result, "PR creation failed", recovery: "carson deliver" )
166
165
  end
167
166
 
@@ -171,8 +170,8 @@ module Carson
171
170
  # Seal the shelf — no more packing until the outcome is confirmed.
172
171
  @warehouse.seal_shelf!( tracking_number: waybill.tracking_number )
173
172
 
174
- # Wait at the registry while the bureaucrats check the parcel.
175
- wait_and_poll_at_registry( waybill, result )
173
+ # Wait at the bureau while the bureaucrats check the parcel.
174
+ wait_and_poll_at_bureau( waybill, result )
176
175
 
177
176
  # Unseal based on outcome:
178
177
  # delivered/held/rejected → unseal (shelf done or parcel returned)
@@ -180,7 +179,7 @@ module Carson
180
179
  outcome = result[ :outcome ]
181
180
  @warehouse.unseal_shelf! if outcome == "delivered" || outcome == "held" || outcome == "rejected"
182
181
 
183
- # Update the ledger with the final outcome.
182
+ # Update the ledger with the final outcome and PR identity.
184
183
  record( parcel, status: outcome || "filed", summary: result[ :hold_reason ], waybill: waybill )
185
184
 
186
185
  result[ :exit ] ||= OK
@@ -189,12 +188,12 @@ module Carson
189
188
 
190
189
  private
191
190
 
192
- # Wait at the registry, polling the bureaucrats up to MAX_CHECKS_AT_REGISTRY
191
+ # Wait at the bureau, polling the bureaucrats up to MAX_CHECKS_AT_BUREAU
193
192
  # times. The courier stays until a definitive answer comes back or the
194
193
  # checks are exhausted.
195
- def wait_and_poll_at_registry( waybill, result )
196
- MAX_CHECKS_AT_REGISTRY.times do |check|
197
- waybill.refresh!
194
+ def wait_and_poll_at_bureau( waybill, result )
195
+ MAX_CHECKS_AT_BUREAU.times do |check|
196
+ @warehouse.check_parcel_at_bureau_with( waybill )
198
197
 
199
198
  # 14/17. Already accepted — parcel is in the registry.
200
199
  if waybill.accepted?
@@ -210,9 +209,9 @@ module Carson
210
209
  return
211
210
  end
212
211
 
213
- # Cleared or mergeability pending — try to accept.
212
+ # Cleared or mergeability pending — ask the warehouse to register.
214
213
  if waybill.cleared? || waybill.mergeability_pending?
215
- waybill.accept!( method: @merge_method )
214
+ @warehouse.register_parcel_at_bureau_with!( waybill, method: @merge_method )
216
215
 
217
216
  if waybill.accepted?
218
217
  result[ :outcome ] = "delivered"
@@ -226,19 +225,25 @@ module Carson
226
225
  result[ :outcome ] = "held"
227
226
  result[ :exit ] = BLOCKED
228
227
  result[ :hold_reason ] = waybill.hold_reason
228
+ result[ :hold_summary ] = waybill.hold_summary( remote_main: result[ :remote_main ] )
229
+ result[ :diagnostic ] = waybill.ci_diagnostic
229
230
  return
230
231
  end
231
232
 
232
- # Report progress — the courier tells what the bureaucrats said.
233
- say "#{waybill.hold_summary} (#{check + 1}/#{MAX_CHECKS_AT_REGISTRY})..."
233
+ # Report progress — client-language summary from the waybill.
234
+ summary = waybill.hold_summary( remote_main: result[ :remote_main ] )
235
+ detail = waybill.ci_diagnostic ? " \u2014 #{waybill.ci_diagnostic}" : ""
236
+ say "#{summary}#{detail} (#{check + 1}/#{MAX_CHECKS_AT_BUREAU})..."
234
237
 
235
238
  # Still waiting — pause before the next check.
236
- pause_between_polls unless check == MAX_CHECKS_AT_REGISTRY - 1
239
+ pause_between_polls unless check == MAX_CHECKS_AT_BUREAU - 1
237
240
  end
238
241
 
239
242
  # Exhausted all checks — bureau hasn't given a definitive answer.
240
243
  result[ :outcome ] = "filed"
241
244
  result[ :hold_reason ] = waybill.hold_reason
245
+ result[ :hold_summary ] = waybill.hold_summary( remote_main: result[ :remote_main ] )
246
+ result[ :diagnostic ] = waybill.ci_diagnostic
242
247
  end
243
248
 
244
249
  # Is the waybill blocked by something that won't resolve by waiting?
@@ -247,8 +252,8 @@ module Carson
247
252
  def definitively_blocked?( waybill )
248
253
  return false unless waybill.held?
249
254
  reason = waybill.hold_reason
250
- [ "failed_at_registry", "merge_conflict",
251
- "behind_registry", "policy_block", "draft" ].include?( reason )
255
+ [ "failed_at_bureau", "merge_conflict",
256
+ "behind_bureau", "policy_block", "draft" ].include?( reason )
252
257
  end
253
258
 
254
259
  # The courier speaks — reports progress to whoever is listening.
@@ -258,11 +263,11 @@ module Carson
258
263
 
259
264
  # Pause between poll checks. Overridable for test isolation.
260
265
  def pause_between_polls
261
- sleep @poll_interval_at_registry
266
+ sleep @poll_interval_at_bureau
262
267
  end
263
268
 
264
269
  # Record a delivery state change in the ledger.
265
- # No-op when no ledger is injected (e.g. tests).
270
+ # When a waybill is provided, its PR identity is persisted.
266
271
  def record( parcel, status:, summary: nil, waybill: nil )
267
272
  return unless @ledger
268
273
 
@@ -202,7 +202,7 @@ module Carson
202
202
  exit_code: EXIT_BLOCK
203
203
  } )
204
204
  else
205
- puts_line "Workbench is sealedparcel in flight (PR ##{tracking_number})."
205
+ puts_line "Branch is locked — PR ##{tracking_number} in flight."
206
206
  puts_line " \u2192 carson worktree create <name>"
207
207
  end
208
208
  EXIT_BLOCK
@@ -22,7 +22,7 @@ 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_bureau: config.poll_interval_at_bureau,
26
26
  output: output
27
27
  )
28
28
 
@@ -55,7 +55,7 @@ module Carson
55
55
 
56
56
  # Render the OO result — JSON or human via Carson.report.
57
57
  def deliver_oo_finish( result:, json_output: )
58
- format = json_output ? :json : :human
58
+ format = json_output ? :json : :text
59
59
  Carson.report( result, format: format, output: output )
60
60
  result[ :exit ] || Courier::OK
61
61
  end
@@ -262,7 +262,7 @@ module Carson
262
262
 
263
263
  next unless current_head == delivery.head
264
264
 
265
- reason = "integrated delivery recorded in ledger"
265
+ reason = "merged delivery recorded"
266
266
  reaped = reap_one_worktree!( worktree: worktree, reason: reason )
267
267
  next unless reaped
268
268
 
@@ -127,6 +127,16 @@ module Carson
127
127
  )
128
128
  end
129
129
 
130
+ # No PR number — courier failed to record it. Cannot reconcile against GitHub.
131
+ unless delivery.pull_request_number
132
+ return ledger.update_delivery(
133
+ delivery: delivery,
134
+ status: "failed",
135
+ cause: "policy",
136
+ summary: "PR number missing from delivery record — run carson deliver to refile"
137
+ )
138
+ end
139
+
130
140
  pr_state = pull_request_state( number: delivery.pull_request_number )
131
141
  if pr_state && pr_state[ "state" ] == "MERGED"
132
142
  return ledger.update_delivery(
@@ -1,9 +1,10 @@
1
1
  # A governed repository. In the FedEx metaphor, the warehouse is where
2
2
  # parcels (committed changes) are stored on shelves (worktrees) with
3
- # labels (branches). Git commands are hidden inside — callers never
4
- # see git terms.
3
+ # labels (branches). Git and gh commands are hidden inside — callers
4
+ # never see git or GitHub terms.
5
5
  require "digest"
6
6
  require "fileutils"
7
+ require "json"
7
8
  require "open3"
8
9
 
9
10
  module Carson
@@ -97,7 +98,7 @@ module Carson
97
98
  # Returns true on success, false on failure.
98
99
  def pack!( message: )
99
100
  if sealed?
100
- raise "Shelf is sealedparcel in flight (PR ##{sealed_tracking_number}). " \
101
+ raise "Branch is locked — PR ##{sealed_tracking_number} in flight. " \
101
102
  "Create a new worktree to continue working."
102
103
  end
103
104
  git( "add", "-A" )
@@ -170,6 +171,62 @@ module Carson
170
171
  end
171
172
  end
172
173
 
174
+ # --- Bureau interaction ---
175
+ # The warehouse owns the connection to the bureau (GitHub).
176
+ # It queries, files, and registers on behalf of the courier.
177
+
178
+ # Check the parcel's status at the bureau using the waybill.
179
+ # Calls gh pr view + gh pr checks. Records findings onto the waybill.
180
+ def check_parcel_at_bureau_with( waybill )
181
+ state = fetch_pr_state_for( waybill.tracking_number )
182
+ ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
183
+ waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
184
+ end
185
+
186
+ # File a waybill at the bureau for this parcel.
187
+ # Calls gh pr create. Returns a Waybill with tracking number, or nil on failure.
188
+ def file_waybill_for!( parcel, title: nil, body_file: nil )
189
+ filing_title = title || Waybill.default_title_for( parcel.label )
190
+ arguments = [ "pr", "create", "--title", filing_title, "--head", parcel.label ]
191
+
192
+ if body_file && File.exist?( body_file )
193
+ arguments.push( "--body-file", body_file )
194
+ else
195
+ arguments.push( "--body", "" )
196
+ end
197
+
198
+ stdout, _, status = gh( *arguments )
199
+ tracking_number = nil
200
+ url = nil
201
+
202
+ if status.success?
203
+ url = stdout.to_s.strip
204
+ tracking_number = url.split( "/" ).last.to_i
205
+ tracking_number = nil if tracking_number == 0
206
+ end
207
+
208
+ # If create failed or returned no number, try to find an existing PR.
209
+ unless tracking_number
210
+ tracking_number, url = find_existing_waybill_for( parcel.label )
211
+ end
212
+
213
+ return nil unless tracking_number
214
+
215
+ Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
216
+ end
217
+
218
+ # Register the parcel at the bureau using the waybill.
219
+ # Calls gh pr merge. Stamps the waybill on success.
220
+ def register_parcel_at_bureau_with!( waybill, method: )
221
+ _, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
222
+ if status.success?
223
+ waybill.stamp( :accepted )
224
+ else
225
+ # Re-check the state — the merge may have revealed a new blocker.
226
+ check_parcel_at_bureau_with( waybill )
227
+ end
228
+ end
229
+
173
230
  # --- Inventory ---
174
231
 
175
232
  # All shelves (worktree paths).
@@ -217,5 +274,63 @@ module Carson
217
274
  def git( *arguments )
218
275
  Open3.capture3( "git", "-C", path, *arguments )
219
276
  end
277
+
278
+ # All gh commands go through this single gateway.
279
+ # Returns [stdout, stderr, status].
280
+ def gh( *arguments )
281
+ Open3.capture3( "gh", *arguments, chdir: path )
282
+ end
283
+
284
+ # Fetch PR state from the bureau for a tracking number.
285
+ # Returns the parsed state hash, or nil on failure.
286
+ def fetch_pr_state_for( tracking_number )
287
+ stdout, _, status = gh(
288
+ "pr", "view", tracking_number.to_s,
289
+ "--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
290
+ )
291
+ return nil unless status.success?
292
+
293
+ JSON.parse( stdout )
294
+ rescue JSON::ParserError
295
+ nil
296
+ end
297
+
298
+ # Fetch CI state from the bureau for a tracking number.
299
+ # Returns [ci_symbol, diagnostic_or_nil].
300
+ # Captures the first line of stderr as diagnostic when the command fails.
301
+ def fetch_ci_state_for( tracking_number )
302
+ stdout, stderr, status = gh(
303
+ "pr", "checks", tracking_number.to_s,
304
+ "--json", "name,bucket"
305
+ )
306
+ unless status.success?
307
+ return [ :error, stderr.to_s.strip.lines.first&.strip ]
308
+ end
309
+
310
+ checks = JSON.parse( stdout ) rescue []
311
+ return [ :none, nil ] if checks.empty?
312
+
313
+ buckets = checks.map { it[ "bucket" ].to_s.downcase }
314
+ return [ :fail, nil ] if buckets.include?( "fail" )
315
+ return [ :pending, nil ] if buckets.include?( "pending" )
316
+
317
+ [ :pass, nil ]
318
+ end
319
+
320
+ # Try to find an existing PR for this label at the bureau.
321
+ # Returns [tracking_number, url] or [nil, nil].
322
+ def find_existing_waybill_for( label )
323
+ stdout, _, status = gh(
324
+ "pr", "view", label,
325
+ "--json", "number,url,state"
326
+ )
327
+ if status.success?
328
+ data = JSON.parse( stdout ) rescue nil
329
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
330
+ return [ data[ "number" ], data[ "url" ].to_s ]
331
+ end
332
+ end
333
+ [ nil, nil ]
334
+ end
220
335
  end
221
336
  end
@@ -1,35 +1,29 @@
1
1
  # The shipping document filed with the bureau (GitHub PR).
2
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.
3
+ # A waybill is a data object it records findings and answers questions.
4
+ # It does not fetch, file, or accept anything. The warehouse handles all
5
+ # bureau interaction and writes findings onto the waybill.
7
6
  #
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"
7
+ # The waybill has a tracking number (PR number), a label (branch name),
8
+ # and records the bureau's response (cleared/held/accepted/rejected).
9
+ # ci_diagnostic preserves the first line of stderr when CI checks fail.
15
10
 
16
11
  module Carson
17
12
  # 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.
13
+ # tracking number, records the bureaucrats' response (cleared/held/
14
+ # accepted/rejected). A data object state is written onto it by
15
+ # the warehouse, never fetched by the waybill itself.
22
16
  class Waybill
23
- attr_reader :tracking_number, :url, :label
17
+ attr_reader :tracking_number, :url, :label, :ci_diagnostic
24
18
 
25
- def initialize( label:, warehouse_path:, tracking_number: nil, url: nil, review_gate: nil )
19
+ def initialize( label:, tracking_number: nil, url: nil )
26
20
  @label = label
27
- @warehouse_path = warehouse_path
28
21
  @tracking_number = tracking_number
29
22
  @url = url
30
- @review_gate = review_gate
31
23
  @state = nil
32
24
  @ci = nil
25
+ @ci_diagnostic = nil
26
+ @verdict = nil
33
27
  end
34
28
 
35
29
  # --- Filing ---
@@ -39,53 +33,46 @@ module Carson
39
33
  !tracking_number.nil?
40
34
  end
41
35
 
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
36
+ # Generate a title from the label. Class method so the warehouse
37
+ # can compute the title before creating the waybill.
38
+ def self.default_title_for( label )
39
+ label.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) do |character|
40
+ character.upcase
58
41
  end
59
-
60
- # If create failed or returned no number, try to find existing.
61
- find_existing! unless filed?
62
- self
63
42
  end
64
43
 
65
- # Generate a human-readable title from the label.
44
+ # Instance convenience delegates to the class method.
66
45
  def default_title
67
- label.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) do |character|
68
- character.upcase
69
- end
46
+ self.class.default_title_for( label )
70
47
  end
71
48
 
72
- # --- Bureau's response ---
49
+ # --- Recorded state ---
50
+
51
+ # Record findings from a bureau check onto the waybill.
52
+ # Called by the warehouse after querying the bureau.
53
+ def record( state:, ci:, ci_diagnostic: nil )
54
+ @state = state
55
+ @ci = ci
56
+ @ci_diagnostic = ci_diagnostic
57
+ end
73
58
 
74
- # Check with the bureau for the latest on this waybill.
75
- def refresh!
76
- @state = fetch_state
77
- @ci = fetch_ci
78
- self
59
+ # Stamp the waybill with a verdict.
60
+ # Called by the warehouse after registering the parcel at the bureau.
61
+ def stamp( verdict )
62
+ @verdict = verdict
79
63
  end
80
64
 
65
+ # --- Bureau's response queries ---
66
+
81
67
  # Has the bureau accepted the parcel into the registry?
68
+ # True when stamped :accepted OR when the recorded state shows MERGED.
82
69
  def accepted?
83
- @state&.dig( "state" ) == "MERGED"
70
+ @verdict == :accepted || @state&.dig( "state" ) == "MERGED"
84
71
  end
85
72
 
86
73
  # Has the bureau rejected the waybill (closed without merge)?
87
74
  def rejected?
88
- @state&.dig( "state" ) == "CLOSED"
75
+ @verdict == :rejected || @state&.dig( "state" ) == "CLOSED"
89
76
  end
90
77
 
91
78
  # Is the waybill still a draft?
@@ -111,29 +98,31 @@ module Carson
111
98
  filed?
112
99
  end
113
100
 
114
- # Why is the waybill being held?
101
+ # Why is the waybill being held? Code string for recovery step lookup.
115
102
  def hold_reason
116
103
  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
104
+ return "pending_at_bureau" if @ci == :pending
105
+ return "failed_at_bureau" if @ci == :fail
106
+ return "error_at_bureau" if @ci == :error
120
107
  return "merge_conflict" if merge_conflicting?
121
- return "behind_registry" if merge_behind?
108
+ return "behind_bureau" if merge_behind?
122
109
  return "policy_block" if merge_policy_blocked?
123
110
  "mergeability_pending"
124
111
  end
125
112
 
126
- # Human-readable explanation of why the waybill is held.
127
- def hold_summary
113
+ # Client-language summary of why the waybill is held.
114
+ # Agents read this directly — no translation layer needed.
115
+ def hold_summary( remote_main: "github/main" )
128
116
  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"
117
+ when "draft" then "PR is still a draft."
118
+ when "pending_at_bureau" then "Waiting for CI checks."
119
+ when "failed_at_bureau" then "CI checks failed."
120
+ when "error_at_bureau" then "Unable to assess CI checks."
121
+ when "merge_conflict" then "Merge conflict with #{remote_main}."
122
+ when "behind_bureau" then "Branch is behind #{remote_main}."
123
+ when "policy_block" then "Blocked by branch protection rules."
124
+ when "mergeability_pending" then "GitHub is calculating mergeability."
125
+ else "Waiting for merge readiness."
137
126
  end
138
127
  end
139
128
 
@@ -142,16 +131,6 @@ module Carson
142
131
  hold_reason == "mergeability_pending"
143
132
  end
144
133
 
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
134
  # --- Observation data for delivery records ---
156
135
 
157
136
  # Returns a hash of the bureau's current state for tracking records.
@@ -165,14 +144,6 @@ module Carson
165
144
  }
166
145
  end
167
146
 
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
147
  private
177
148
 
178
149
  def merge_conflicting?
@@ -188,56 +159,5 @@ module Carson
188
159
  def merge_policy_blocked?
189
160
  @state&.dig( "mergeStateStatus" ).to_s.upcase == "BLOCKED"
190
161
  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
162
  end
243
163
  end
data/lib/carson.rb CHANGED
@@ -5,21 +5,21 @@ module Carson
5
5
  BADGE = "\u29D3".freeze # ⧓ BLACK BOWTIE (U+29D3)
6
6
 
7
7
  # The company renders results for whoever is listening.
8
- # JSON is the primary format (agents consume it). Human-readable is secondary.
8
+ # JSON is the primary format (agents consume it). Text is secondary.
9
9
  # Domain objects return result hashes — Carson decides how to present them.
10
10
  def self.report( result, format: :json, output: $stdout )
11
11
  case format
12
12
  when :json
13
13
  require "json"
14
14
  output.puts JSON.pretty_generate( result )
15
- when :human
16
- report_human( result, output: output )
15
+ when :text
16
+ report_text( result, output: output )
17
17
  end
18
18
  end
19
19
 
20
- # Human-readable delivery report — technical language for agents and humans.
20
+ # Text delivery report — client language for agents.
21
21
  # Story language is internal (source code). Output speaks the client's language.
22
- def self.report_human( result, output: $stdout )
22
+ def self.report_text( result, output: $stdout )
23
23
  if result[ :error ]
24
24
  output.puts "#{BADGE} #{result[ :error ]}"
25
25
  output.puts " \u2192 #{result[ :recovery ]}" if result[ :recovery ]
@@ -40,45 +40,39 @@ module Carson
40
40
  output.puts "#{BADGE} Local main not synced \u2014 run carson sync."
41
41
  end
42
42
  when "held"
43
- diagnosis, *recovery_steps = translate_hold( result[ :hold_reason ], remote_main: remote_main )
44
- output.puts "#{BADGE} #{diagnosis}"
45
- recovery_steps.each do |step|
43
+ summary = result[ :hold_summary ] || "Waiting for merge readiness."
44
+ output.puts "#{BADGE} #{summary}"
45
+ recovery_steps_for_hold( result[ :hold_reason ], remote_main: remote_main ).each do |step|
46
46
  output.puts " \u2192 #{step}"
47
47
  end
48
48
  when "rejected"
49
49
  output.puts "#{BADGE} PR closed externally."
50
50
  when "filed"
51
- output.puts "#{BADGE} Bureau hasn't responded yet. Run carson status to check back."
51
+ summary = result[ :hold_summary ] || "Waiting for merge readiness."
52
+ diagnostic = result[ :diagnostic ] ? " (#{result[ :diagnostic ]})" : ""
53
+ output.puts "#{BADGE} #{summary}#{diagnostic}"
54
+ output.puts " \u2192 carson status"
52
55
  end
53
56
  end
54
57
 
55
- # Translate internal hold reasons to agent-actionable output.
56
- # Returns [ diagnosis, *recovery_steps ]. The diagnosis says what
57
- # happened. Each recovery step is a command the agent can execute.
58
- def self.translate_hold( reason, remote_main: "origin/main" )
58
+ # Recovery commands for a held delivery.
59
+ # The report knows what commands to suggest for each situation.
60
+ def self.recovery_steps_for_hold( reason, remote_main: "origin/main" )
59
61
  case reason
60
- when "draft"
61
- [ "PR is still a draft." ]
62
- when "pending_at_registry"
63
- [ "Waiting for CI checks.", "carson status" ]
64
- when "failed_at_registry"
65
- [ "CI checks failed.", "carson deliver" ]
66
- when "error_at_registry"
67
- [ "Unable to assess CI checks.", "carson status" ]
62
+ when "pending_at_bureau", "mergeability_pending", "error_at_bureau"
63
+ [ "carson status" ]
64
+ when "failed_at_bureau"
65
+ [ "carson deliver" ]
68
66
  when "merge_conflict"
69
- [ "Merge conflict with #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
70
- when "behind_registry"
71
- [ "Branch is behind #{remote_main}.", "carson deliver" ]
72
- when "policy_block"
73
- [ "Blocked by branch protection rules." ]
74
- when "mergeability_pending"
75
- [ "GitHub is calculating mergeability.", "carson status" ]
67
+ [ "git rebase #{remote_main}", "carson deliver" ]
68
+ when "behind_bureau"
69
+ [ "carson deliver" ]
76
70
  else
77
- [ "Waiting for merge readiness.", "carson status" ]
71
+ []
78
72
  end
79
73
  end
80
74
 
81
- private_class_method :report_human
75
+ private_class_method :report_text, :recovery_steps_for_hold
82
76
  end
83
77
 
84
78
  require_relative "carson/repository"
@@ -87,8 +81,8 @@ require_relative "carson/delivery"
87
81
  require_relative "carson/revision"
88
82
  require_relative "carson/ledger"
89
83
  require_relative "carson/parcel"
90
- require_relative "carson/warehouse"
91
84
  require_relative "carson/waybill"
85
+ require_relative "carson/warehouse"
92
86
  require_relative "carson/courier"
93
87
  require_relative "carson/worktree"
94
88
  require_relative "carson/config"
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.3
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang