carson 4.0.2 → 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: 8bf071658d6db37302910554b54a528ac3efe7faa22a1cb88b42d13faf0b6b30
4
- data.tar.gz: 0e30d7ca60c8d8f13438562b003009a64820ff7109ca1c525a127282a5c942c9
3
+ metadata.gz: 4f0dff052fa48cda5c1c0819ff109a9e93c70579c3e450d8af3e82bb55140b54
4
+ data.tar.gz: b2543300a607dccd88aaa3286e3c37b34cb4e847a30b3fb202ee76cc223125fb
5
5
  SHA512:
6
- metadata.gz: 0d50b557c6e2a29b78d4f82dfedf2f89c4d6eacc68fb561d5684eb7c40582ba43de5ff84b6929c992fd98b5069e1ee15b17fb434f4512b3ea30f94c97a387ae6
7
- data.tar.gz: 4cc5c13c062c226ffcfd8627882ecb6c3093169e61dc759d03e2b77110f93323b77bc90761107a8e2a7696b211fe42c603ad0823639c3eb062ff66753410f96b
6
+ metadata.gz: c0b1853fe814ad941a983d4d0676eb1912ec818b3387b3d74ab61241cb413bea296a797b6bb2f7fbbca8b30f978fc6df203a27a1879727bd753c3a615efdf8ec
7
+ data.tar.gz: 3a51ccae965f6f0008123396c0347a3758c8ad28fac60ceabf8a058bb44538afd2988155b9ec895fe0419f0ddf82279c5f86517f2626dec5dfe96b2375877153
data/RELEASE.md CHANGED
@@ -7,9 +7,37 @@ 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.
32
+
33
+ ## 4.0.3
34
+
35
+ ### Fixed
36
+
37
+ - **No-checks repos can now merge** (#465) — `Waybill#cleared?` required CI to pass, but repos with no CI checks returned `:none`. These repos were permanently held. Now `:none` is treated as non-blocking.
38
+ - **Sync failure reported explicitly** — When local main fails to sync after a merge, `carson deliver` now prints "Local main not synced — run carson sync." instead of silently omitting the line.
39
+ - **Freshness fetch failure blocks delivery** — `Courier#deliver` ignored the return value of `fetch_latest`. If the fetch failed (network error), Carson reasoned from stale refs. Now blocks immediately with "cannot verify freshness — fetch failed" and Carson-first recovery.
40
+ - **PR data reaches the ledger** — The Courier's `record` method hardcoded `pr_number: nil` and `pr_url: nil`. Filed deliveries had no PR reference in the ledger. Now passes waybill tracking number and URL.
13
41
 
14
42
  ## 4.0.2
15
43
 
@@ -42,7 +70,7 @@ Release-note scope rule:
42
70
 
43
71
  - **`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.
44
72
  - **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.
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.
73
+ - **Hold messages include recovery commands** — Human output for held deliveries now shows actionable next steps (e.g. `→ carson deliver`) instead of prose descriptions.
46
74
 
47
75
  ### New
48
76
 
@@ -71,7 +99,8 @@ Release-note scope rule:
71
99
 
72
100
  ### What changed
73
101
 
74
- - **Recovery hints re-enter through Carson, not blocked raw commands** — Delivery error recovery messages no longer suggest raw `gh pr create`, `gh pr merge`, or `git rebase` commands. All recovery paths now guide the user back through Carson. Freshness blocks say "refresh this branch onto the target, then `carson deliver`" instead of teaching raw git recipes.
102
+ - **Recovery hints re-enter through Carson, not blocked raw commands** — Delivery error recovery messages no longer suggest raw `gh pr create` or `gh pr merge`. All recovery paths guide the user back through Carson.
103
+ - **Auto-rebase on delivery** — `carson deliver` automatically rebases the branch when it's behind remote main. Only blocks if the rebase hits a conflict. No more manual `git rebase` before delivery.
75
104
  - **Fast PR indentation guard restored** — The Ruby indentation guard in CI now correctly handles access modifier detection, fixing false positives that blocked PRs.
76
105
 
77
106
  ### No migration required
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.0.2
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",
@@ -125,12 +128,21 @@ module Carson
125
128
  parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
126
129
 
127
130
  # 02. Parcel behind standard — not based on client's latest standard.
128
- @warehouse.fetch_latest( registry: @warehouse.main_label )
131
+ # The courier rebases automatically. Only blocks on conflict.
132
+ unless @warehouse.fetch_latest( registry: @warehouse.main_label )
133
+ return blocked( result,
134
+ "cannot verify freshness — fetch failed",
135
+ recovery: "carson sync, then carson deliver" )
136
+ end
129
137
  unless @warehouse.based_on_latest_standard?( parcel )
130
138
  remote_main = "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
131
- return blocked( result,
132
- "branch is behind #{remote_main}",
133
- recovery: "git rebase #{remote_main}, then carson deliver" )
139
+ say "Branch is behind #{remote_main} — rebasing..."
140
+ unless @warehouse.rebase_on_latest_standard!
141
+ return blocked( result,
142
+ "rebase conflict onto #{remote_main}",
143
+ recovery: "resolve conflicts, then carson deliver" )
144
+ end
145
+ parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
134
146
  end
135
147
 
136
148
  # Announce the delivery.
@@ -144,15 +156,11 @@ module Carson
144
156
  return error( result, "push failed" )
145
157
  end
146
158
 
147
- # File a waybill with the bureau.
148
- waybill = Waybill.new(
149
- label: parcel.label,
150
- warehouse_path: @warehouse.path
151
- )
152
- 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 )
153
161
 
154
162
  # 04. Waybill filing fails — bureau rejected the paperwork.
155
- unless waybill.filed?
163
+ unless waybill
156
164
  return error( result, "PR creation failed", recovery: "carson deliver" )
157
165
  end
158
166
 
@@ -162,8 +170,8 @@ module Carson
162
170
  # Seal the shelf — no more packing until the outcome is confirmed.
163
171
  @warehouse.seal_shelf!( tracking_number: waybill.tracking_number )
164
172
 
165
- # Wait at the registry while the bureaucrats check the parcel.
166
- 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 )
167
175
 
168
176
  # Unseal based on outcome:
169
177
  # delivered/held/rejected → unseal (shelf done or parcel returned)
@@ -171,8 +179,8 @@ module Carson
171
179
  outcome = result[ :outcome ]
172
180
  @warehouse.unseal_shelf! if outcome == "delivered" || outcome == "held" || outcome == "rejected"
173
181
 
174
- # Update the ledger with the final outcome.
175
- record( parcel, status: outcome || "filed", summary: result[ :hold_reason ] )
182
+ # Update the ledger with the final outcome and PR identity.
183
+ record( parcel, status: outcome || "filed", summary: result[ :hold_reason ], waybill: waybill )
176
184
 
177
185
  result[ :exit ] ||= OK
178
186
  result
@@ -180,12 +188,12 @@ module Carson
180
188
 
181
189
  private
182
190
 
183
- # 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
184
192
  # times. The courier stays until a definitive answer comes back or the
185
193
  # checks are exhausted.
186
- def wait_and_poll_at_registry( waybill, result )
187
- MAX_CHECKS_AT_REGISTRY.times do |check|
188
- 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 )
189
197
 
190
198
  # 14/17. Already accepted — parcel is in the registry.
191
199
  if waybill.accepted?
@@ -201,9 +209,9 @@ module Carson
201
209
  return
202
210
  end
203
211
 
204
- # Cleared or mergeability pending — try to accept.
212
+ # Cleared or mergeability pending — ask the warehouse to register.
205
213
  if waybill.cleared? || waybill.mergeability_pending?
206
- waybill.accept!( method: @merge_method )
214
+ @warehouse.register_parcel_at_bureau_with!( waybill, method: @merge_method )
207
215
 
208
216
  if waybill.accepted?
209
217
  result[ :outcome ] = "delivered"
@@ -217,19 +225,25 @@ module Carson
217
225
  result[ :outcome ] = "held"
218
226
  result[ :exit ] = BLOCKED
219
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
220
230
  return
221
231
  end
222
232
 
223
- # Report progress — the courier tells what the bureaucrats said.
224
- 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})..."
225
237
 
226
238
  # Still waiting — pause before the next check.
227
- pause_between_polls unless check == MAX_CHECKS_AT_REGISTRY - 1
239
+ pause_between_polls unless check == MAX_CHECKS_AT_BUREAU - 1
228
240
  end
229
241
 
230
242
  # Exhausted all checks — bureau hasn't given a definitive answer.
231
243
  result[ :outcome ] = "filed"
232
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
233
247
  end
234
248
 
235
249
  # Is the waybill blocked by something that won't resolve by waiting?
@@ -238,8 +252,8 @@ module Carson
238
252
  def definitively_blocked?( waybill )
239
253
  return false unless waybill.held?
240
254
  reason = waybill.hold_reason
241
- [ "failed_at_registry", "merge_conflict",
242
- "behind_registry", "policy_block", "draft" ].include?( reason )
255
+ [ "failed_at_bureau", "merge_conflict",
256
+ "behind_bureau", "policy_block", "draft" ].include?( reason )
243
257
  end
244
258
 
245
259
  # The courier speaks — reports progress to whoever is listening.
@@ -249,12 +263,12 @@ module Carson
249
263
 
250
264
  # Pause between poll checks. Overridable for test isolation.
251
265
  def pause_between_polls
252
- sleep @poll_interval_at_registry
266
+ sleep @poll_interval_at_bureau
253
267
  end
254
268
 
255
269
  # Record a delivery state change in the ledger.
256
- # No-op when no ledger is injected (e.g. tests).
257
- def record( parcel, status:, summary: nil )
270
+ # When a waybill is provided, its PR identity is persisted.
271
+ def record( parcel, status:, summary: nil, waybill: nil )
258
272
  return unless @ledger
259
273
 
260
274
  # The ledger needs a repository-like object with .path pointing
@@ -265,8 +279,8 @@ module Carson
265
279
  branch_name: parcel.label,
266
280
  head: parcel.head,
267
281
  worktree_path: @warehouse.path,
268
- pr_number: nil,
269
- pr_url: nil,
282
+ pr_number: waybill&.tracking_number,
283
+ pr_url: waybill&.url,
270
284
  status: status,
271
285
  summary: summary,
272
286
  cause: nil
@@ -1,7 +1,7 @@
1
1
  # Passive ledger record for one branch delivery attempt.
2
2
  module Carson
3
3
  class Delivery
4
- ACTIVE_STATES = %w[preparing gated queued integrating escalated].freeze
4
+ ACTIVE_STATES = %w[preparing gated queued integrating escalated filed].freeze
5
5
  BLOCKED_STATES = %w[gated escalated].freeze
6
6
  READY_STATES = %w[queued].freeze
7
7
  TERMINAL_STATES = %w[integrated failed superseded].freeze
@@ -60,6 +60,10 @@ module Carson
60
60
  READY_STATES.include?( status )
61
61
  end
62
62
 
63
+ def filed?
64
+ status == "filed"
65
+ end
66
+
63
67
  def integrated?
64
68
  status == "integrated"
65
69
  end
@@ -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
@@ -231,7 +231,7 @@ module Carson
231
231
  when :blocked
232
232
  result[ :outcome ] = "blocked"
233
233
  result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
234
- result[ :recovery ] = "refresh this branch onto #{config.git_remote}/#{main}, then carson deliver" if evaluation[ :cause ] == "freshness"
234
+ result[ :recovery ] = "carson deliver" if evaluation[ :cause ] == "freshness"
235
235
  apply_handoff!(
236
236
  result: result,
237
237
  reason: evaluation.fetch( :reason ),
@@ -752,7 +752,7 @@ module Carson
752
752
 
753
753
  def freshness_recovery( freshness: )
754
754
  remote_ref = freshness.fetch( :remote_ref )
755
- return "refresh this branch onto #{remote_ref}, then carson deliver" if freshness.fetch( :status ) == :behind
755
+ return "carson deliver" if freshness.fetch( :status ) == :behind
756
756
 
757
757
  "carson deliver (once #{remote_ref} is reachable)"
758
758
  end
@@ -840,7 +840,7 @@ module Carson
840
840
  reason: "freshness_behind",
841
841
  cause: "freshness",
842
842
  summary: "branch is behind #{remote_main}",
843
- recovery: "refresh this branch onto #{remote_main}, then carson deliver"
843
+ recovery: "carson deliver"
844
844
  } if merge_state == "BEHIND"
845
845
 
846
846
  return {
@@ -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
 
@@ -2,6 +2,13 @@
2
2
  module Carson
3
3
  class Runtime
4
4
  module Local
5
+ # Refreshes hooks only — safe to run regardless of worktree or
6
+ # uncommitted-changes state, because hooks write to ~/.carson/hooks/
7
+ # and only touch .git/config, not the working tree.
8
+ def refresh_hooks!
9
+ prepare!
10
+ end
11
+
5
12
  private
6
13
 
7
14
  # Installs required hook files and enforces repository hook path.
@@ -120,8 +120,14 @@ module Carson
120
120
 
121
121
  safety = portfolio_repo_safety( repo_path: repo_path )
122
122
  unless safety.fetch( :safe )
123
+ # Hooks write to ~/.carson/hooks/, not the repo — always safe to refresh.
124
+ hooks_status = refresh_hooks_single_repo( repo_path: repo_path )
123
125
  reason = safety.fetch( :reasons ).join( ", " )
124
- puts_line "#{repo_name}: PENDING (#{reason})"
126
+ if hooks_status == EXIT_OK
127
+ puts_line "#{repo_name}: hooks refreshed, templates pending (#{reason})"
128
+ else
129
+ puts_line "#{repo_name}: PENDING (#{reason})"
130
+ end
125
131
  record_batch_skip( command: "refresh", repo_path: repo_path, reason: reason )
126
132
  pending += 1
127
133
  next
@@ -304,6 +310,16 @@ module Carson
304
310
  EXIT_ERROR
305
311
  end
306
312
 
313
+ # Refreshes hooks only for a governed repo using a scoped Runtime.
314
+ # Used when the full refresh is blocked by active worktrees or uncommitted
315
+ # changes — hooks write to ~/.carson/hooks/ and do not touch the working tree.
316
+ def refresh_hooks_single_repo( repo_path: )
317
+ scoped_runtime = build_scoped_runtime( repo_path: repo_path )
318
+ scoped_runtime.refresh_hooks!
319
+ rescue StandardError
320
+ EXIT_ERROR
321
+ end
322
+
307
323
  def refresh_status_label( status: )
308
324
  case status
309
325
  when EXIT_OK then "OK"
@@ -74,7 +74,23 @@ module Carson
74
74
 
75
75
  puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}" unless silent
76
76
 
77
+ # Collect worktree paths of filed deliveries before reconciliation.
78
+ # Receive takes over lifecycle management — the courier's polling window is over.
79
+ filed_worktree_paths = deliveries
80
+ .select( &:filed? )
81
+ .map( &:worktree_path )
82
+ .compact
83
+ .reject { |path| path.to_s.strip.empty? }
84
+
77
85
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
86
+
87
+ # Unseal worktrees that were filed — receive now owns the delivery lifecycle.
88
+ unless dry_run
89
+ filed_worktree_paths.each do |worktree_path|
90
+ Warehouse.new( path: worktree_path ).unseal_shelf!
91
+ end
92
+ end
93
+
78
94
  next_to_integrate = reconciled.find( &:ready? )&.key
79
95
 
80
96
  reconciled.each do |delivery|
@@ -111,6 +127,16 @@ module Carson
111
127
  )
112
128
  end
113
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
+
114
140
  pr_state = pull_request_state( number: delivery.pull_request_number )
115
141
  if pr_state && pr_state[ "state" ] == "MERGED"
116
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?
@@ -98,7 +85,7 @@ module Carson
98
85
  def cleared?
99
86
  return false unless filed?
100
87
  return false if draft?
101
- return false unless @ci == :pass
88
+ return false unless @ci == :pass || @ci == :none
102
89
  return false if merge_conflicting? || merge_behind? || merge_policy_blocked?
103
90
  merge_status = @state&.dig( "mergeStateStatus" ).to_s.upcase
104
91
  mergeable = @state&.dig( "mergeable" ).to_s.upcase
@@ -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 ]
@@ -34,47 +34,45 @@ module Carson
34
34
  case result[ :outcome ]
35
35
  when "delivered"
36
36
  output.puts "#{BADGE} Merged."
37
- output.puts "#{BADGE} Local main synced." if result[ :synced ]
37
+ if result[ :synced ]
38
+ output.puts "#{BADGE} Local main synced."
39
+ elsif result.key?( :synced )
40
+ output.puts "#{BADGE} Local main not synced \u2014 run carson sync."
41
+ end
38
42
  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|
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|
42
46
  output.puts " \u2192 #{step}"
43
47
  end
44
48
  when "rejected"
45
49
  output.puts "#{BADGE} PR closed externally."
46
50
  when "filed"
47
- 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"
48
55
  end
49
56
  end
50
57
 
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" )
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" )
55
61
  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" ]
62
+ when "pending_at_bureau", "mergeability_pending", "error_at_bureau"
63
+ [ "carson status" ]
64
+ when "failed_at_bureau"
65
+ [ "carson deliver" ]
64
66
  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" ]
67
+ [ "git rebase #{remote_main}", "carson deliver" ]
68
+ when "behind_bureau"
69
+ [ "carson deliver" ]
72
70
  else
73
- [ "Waiting for merge readiness.", "carson status" ]
71
+ []
74
72
  end
75
73
  end
76
74
 
77
- private_class_method :report_human
75
+ private_class_method :report_text, :recovery_steps_for_hold
78
76
  end
79
77
 
80
78
  require_relative "carson/repository"
@@ -83,8 +81,8 @@ require_relative "carson/delivery"
83
81
  require_relative "carson/revision"
84
82
  require_relative "carson/ledger"
85
83
  require_relative "carson/parcel"
86
- require_relative "carson/warehouse"
87
84
  require_relative "carson/waybill"
85
+ require_relative "carson/warehouse"
88
86
  require_relative "carson/courier"
89
87
  require_relative "carson/worktree"
90
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.2
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang