carson 3.2.0 → 3.4.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: 21eb861872b515965379fc3dc8afe24fceb228589b479e475999116aca5eca2a
4
- data.tar.gz: da1ac628ac9da29969d973a5265c7ddff472370c1865e08af69f8f7bff3203e5
3
+ metadata.gz: 514509a6422a4c2237a48feac1bff1c10e4705afe62fb574d400b8364cf131ba
4
+ data.tar.gz: 1f7bbf53a01ee92d8a97d62d0a7957bb823c0aabd70e31a7e27e37de8ab3b8c6
5
5
  SHA512:
6
- metadata.gz: ebafee0b573830243f4706fd5884ab9cece7ec07ecf657b936ea76d48c79af9eec30a9af2ac23ea1d7e7fbad604d49573aa67c2c53e9a500f16225684e81c67c
7
- data.tar.gz: 0aea5423f60e9d33ca4f42f15b5f7bc4ecfd219960cec6d65d0c868fdc53a44831cb0eb4d12264f489b1fd7baafbf55b7226386b9fb1a7e8e4f360b06f9106f7
6
+ metadata.gz: a536b8a85ba6b2a737ce913485e3d58b5672b6b13af40261b181dd234bf1052ffa93a5f9b51307d9ce7e433e588b92ce294e88118b758747d4bb5cbad898c6c5
7
+ data.tar.gz: d476ba9ed57c7c97967c0cb2fab8b8390debfa196d657ae6d2e6c51e143248f8160bc543eb0d9b4e5c3c434ba4a25aa16b8af9aa6ec3b9cfcf5b7c974cd8fa9e
data/RELEASE.md CHANGED
@@ -5,6 +5,40 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 3.4.0 — Audit JSON
9
+
10
+ ### What changed
11
+
12
+ - **`carson audit --json`** — machine-readable JSON output for the audit command. The JSON envelope includes `command`, `status`, `branch`, `hooks`, `main_sync`, `pr`, `checks`, `baseline`, `problems`, and `exit_code`. Agents can parse audit results programmatically instead of regex-matching human-readable text.
13
+
14
+ ### UX
15
+
16
+ - JSON output is only produced when `--json` is passed; default human-readable output is unchanged.
17
+ - The `problems` array contains the same concise problem strings shown in non-verbose human output, making it easy for agents to surface actionable issues.
18
+ - `hooks.status` is `"ok"` or `"mismatch"`, `main_sync.status` is `"ok"`, `"ahead"`, `"behind"`, or `"unknown"`.
19
+
20
+ ### Migration
21
+
22
+ - No breaking changes. `carson audit` without `--json` behaves identically to 3.3.0.
23
+ - The `audit!` method now accepts `json_output:` keyword argument (default `false`).
24
+
25
+ ## 3.3.0 — Deliver Refinements
26
+
27
+ ### What changed
28
+
29
+ - **`carson deliver --json`** — machine-readable JSON output for agent consumption. The JSON envelope includes `command`, `branch`, `pr_number`, `pr_url`, `ci`, `merged`, `exit_code`, and — on failure — `error` and `recovery` fields.
30
+ - **Recovery-aware errors** — every deliver error path now includes a concrete `recovery` command showing the user exactly what to run next. Human output shows `Recovery: <command>`, JSON output includes a `recovery` field.
31
+
32
+ ### UX
33
+
34
+ - JSON output uses `JSON.pretty_generate` for readability when inspected by humans.
35
+ - Recovery commands are context-specific: push failures suggest `git pull --rebase && git push`, main-branch errors suggest `git checkout -b <branch>`, CI failures suggest `gh pr checks` with re-deliver.
36
+ - Human output for CI pending and CI fail states now includes recovery guidance.
37
+
38
+ ### Migration
39
+
40
+ - No breaking changes. `carson deliver` without `--json` behaves identically to 3.2.0.
41
+
8
42
  ## 3.2.0 — Deliver
9
43
 
10
44
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 3.4.0
data/lib/carson/cli.rb CHANGED
@@ -53,7 +53,7 @@ module Carson
53
53
 
54
54
  def self.build_parser
55
55
  OptionParser.new do |opts|
56
- opts.banner = "Usage: carson [status [--json]|setup|audit|sync|deliver [--merge] [--title T] [--body-file F]|prune [--all]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
57
57
  end
58
58
  end
59
59
 
@@ -88,6 +88,8 @@ module Carson
88
88
  parse_worktree_subcommand( argv: argv, parser: parser, err: err )
89
89
  when "review"
90
90
  parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
91
+ when "audit"
92
+ parse_audit_command( argv: argv, err: err )
91
93
  when "status"
92
94
  parse_status_command( argv: argv, err: err )
93
95
  when "deliver"
@@ -242,6 +244,15 @@ module Carson
242
244
  { command: :invalid }
243
245
  end
244
246
 
247
+ def self.parse_audit_command( argv:, err: )
248
+ json_flag = argv.delete( "--json" ) ? true : false
249
+ unless argv.empty?
250
+ err.puts "#{BADGE} Unexpected arguments for audit: #{argv.join( ' ' )}"
251
+ return { command: :invalid }
252
+ end
253
+ { command: "audit", json: json_flag }
254
+ end
255
+
245
256
  def self.parse_status_command( argv:, err: )
246
257
  json_flag = argv.delete( "--json" ) ? true : false
247
258
  unless argv.empty?
@@ -252,10 +263,11 @@ module Carson
252
263
  end
253
264
 
254
265
  def self.parse_deliver_command( argv:, err: )
255
- options = { merge: false, title: nil, body_file: nil }
266
+ options = { merge: false, json: false, title: nil, body_file: nil }
256
267
  deliver_parser = OptionParser.new do |opts|
257
- opts.banner = "Usage: carson deliver [--merge] [--title TITLE] [--body-file PATH]"
268
+ opts.banner = "Usage: carson deliver [--merge] [--json] [--title TITLE] [--body-file PATH]"
258
269
  opts.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
270
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
259
271
  opts.on( "--title TITLE", "PR title (defaults to branch name)" ) { |v| options[ :title ] = v }
260
272
  opts.on( "--body-file PATH", "File containing PR body text" ) { |v| options[ :body_file ] = v }
261
273
  end
@@ -268,6 +280,7 @@ module Carson
268
280
  {
269
281
  command: "deliver",
270
282
  merge: options.fetch( :merge ),
283
+ json: options.fetch( :json ),
271
284
  title: options[ :title ],
272
285
  body_file: options[ :body_file ]
273
286
  }
@@ -319,7 +332,7 @@ module Carson
319
332
  when "setup"
320
333
  runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
321
334
  when "audit"
322
- runtime.audit!
335
+ runtime.audit!( json_output: parsed.fetch( :json, false ) )
323
336
  when "sync"
324
337
  runtime.sync!
325
338
  when "prune"
@@ -348,7 +361,8 @@ module Carson
348
361
  runtime.deliver!(
349
362
  merge: parsed.fetch( :merge, false ),
350
363
  title: parsed.fetch( :title, nil ),
351
- body_file: parsed.fetch( :body_file, nil )
364
+ body_file: parsed.fetch( :body_file, nil ),
365
+ json_output: parsed.fetch( :json, false )
352
366
  )
353
367
  when "review:gate"
354
368
  runtime.review_gate!
@@ -1,13 +1,20 @@
1
+ # Pre-commit audit — checks hooks, main sync, PR checks, and CI baseline.
2
+ # Exits with EXIT_BLOCK when policy violations are found.
3
+ # Supports --json for machine-readable structured output.
1
4
  require "cgi"
2
5
 
3
6
  module Carson
4
7
  class Runtime
5
8
  module Audit
6
- def audit!
9
+ def audit!( json_output: false )
7
10
  fingerprint_status = block_if_outsider_fingerprints!
8
11
  return fingerprint_status unless fingerprint_status.nil?
9
12
  unless head_exists?
10
- puts_line "No commits yet — audit skipped for initial commit."
13
+ if json_output
14
+ out.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
15
+ else
16
+ puts_line "No commits yet — audit skipped for initial commit."
17
+ end
11
18
  return EXIT_OK
12
19
  end
13
20
  audit_state = "ok"
@@ -22,6 +29,7 @@ module Carson
22
29
  puts_verbose ""
23
30
  puts_verbose "[Hooks]"
24
31
  hooks_ok = hooks_health_report
32
+ hooks_status = hooks_ok ? "ok" : "mismatch"
25
33
  unless hooks_ok
26
34
  audit_state = "block"
27
35
  audit_concise_problems << "Hooks: mismatch — run carson refresh."
@@ -29,23 +37,27 @@ module Carson
29
37
  puts_verbose ""
30
38
  puts_verbose "[Main Sync Status]"
31
39
  ahead_count, behind_count, main_error = main_sync_counts
40
+ main_sync = { ahead: 0, behind: 0, status: "ok" }
32
41
  if main_error
33
42
  puts_verbose "main_vs_remote_main: unknown"
34
43
  puts_verbose "WARN: unable to calculate main sync status (#{main_error})."
35
44
  audit_state = "attention" if audit_state == "ok"
36
45
  audit_concise_problems << "Main sync: unable to determine — check remote connectivity."
46
+ main_sync = { ahead: 0, behind: 0, status: "unknown", error: main_error }
37
47
  elsif ahead_count.positive?
38
48
  puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
39
49
  puts_verbose "main_vs_remote_main_behind: #{behind_count}"
40
50
  puts_verbose "ACTION: local #{config.main_branch} is ahead of #{config.git_remote}/#{config.main_branch} by #{ahead_count} commit#{plural_suffix( count: ahead_count )}; reset local drift before commit/push workflows."
41
51
  audit_state = "block"
42
52
  audit_concise_problems << "Main sync (#{config.git_remote}): ahead by #{ahead_count} — git fetch #{config.git_remote}, or carson setup to switch remote."
53
+ main_sync = { ahead: ahead_count, behind: behind_count, status: "ahead" }
43
54
  elsif behind_count.positive?
44
55
  puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
45
56
  puts_verbose "main_vs_remote_main_behind: #{behind_count}"
46
57
  puts_verbose "ACTION: local #{config.main_branch} is behind #{config.git_remote}/#{config.main_branch} by #{behind_count} commit#{plural_suffix( count: behind_count )}; run carson sync."
47
58
  audit_state = "attention" if audit_state == "ok"
48
59
  audit_concise_problems << "Main sync (#{config.git_remote}): behind by #{behind_count} — run carson sync."
60
+ main_sync = { ahead: ahead_count, behind: behind_count, status: "behind" }
49
61
  else
50
62
  puts_verbose "main_vs_remote_main_ahead: 0"
51
63
  puts_verbose "main_vs_remote_main_behind: 0"
@@ -109,15 +121,40 @@ module Carson
109
121
  audit_status: audit_state
110
122
  )
111
123
  )
112
- puts_verbose ""
113
- puts_verbose "[Audit Result]"
114
- puts_verbose "status: #{audit_state}"
115
- puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
116
- unless verbose?
117
- audit_concise_problems.each { |problem| puts_line problem }
118
- puts_line "Audit: #{audit_state}"
124
+ exit_code = audit_state == "block" ? EXIT_BLOCK : EXIT_OK
125
+
126
+ if json_output
127
+ result = {
128
+ command: "audit",
129
+ status: audit_state,
130
+ branch: current_branch,
131
+ hooks: { status: hooks_status },
132
+ main_sync: main_sync,
133
+ pr: monitor_report[ :pr ],
134
+ checks: monitor_report.fetch( :checks ),
135
+ baseline: {
136
+ status: default_branch_baseline.fetch( :status ),
137
+ repository: default_branch_baseline[ :repository ],
138
+ failing_count: default_branch_baseline.fetch( :failing_count ),
139
+ pending_count: default_branch_baseline.fetch( :pending_count ),
140
+ advisory_failing_count: default_branch_baseline.fetch( :advisory_failing_count ),
141
+ advisory_pending_count: default_branch_baseline.fetch( :advisory_pending_count )
142
+ },
143
+ problems: audit_concise_problems,
144
+ exit_code: exit_code
145
+ }
146
+ out.puts JSON.pretty_generate( result )
147
+ else
148
+ puts_verbose ""
149
+ puts_verbose "[Audit Result]"
150
+ puts_verbose "status: #{audit_state}"
151
+ puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
152
+ unless verbose?
153
+ audit_concise_problems.each { |problem| puts_line problem }
154
+ puts_line "Audit: #{audit_state}"
155
+ end
119
156
  end
120
- audit_state == "block" ? EXIT_BLOCK : EXIT_OK
157
+ exit_code
121
158
  end
122
159
 
123
160
  private
@@ -2,73 +2,135 @@
2
2
  # Collapses the 8-step manual PR flow into one or two commands.
3
3
  # `carson deliver` pushes and creates the PR.
4
4
  # `carson deliver --merge` also merges if CI is green.
5
+ # `carson deliver --json` outputs structured result for agent consumption.
5
6
  module Carson
6
7
  class Runtime
7
8
  module Deliver
8
9
  # Entry point for `carson deliver`.
9
10
  # Pushes current branch, creates a PR if needed, reports the PR URL.
10
11
  # With merge: true, also merges if CI passes and cleans up.
11
- def deliver!( merge: false, title: nil, body_file: nil )
12
+ def deliver!( merge: false, title: nil, body_file: nil, json_output: false )
12
13
  branch = current_branch
13
14
  main = config.main_branch
14
15
  remote = config.git_remote
16
+ result = { command: "deliver", branch: branch }
15
17
 
16
18
  # Guard: cannot deliver from main.
17
19
  if branch == main
18
- puts_line "ERROR: cannot deliver from #{main}. Switch to a feature branch first."
19
- return EXIT_ERROR
20
+ result[ :error ] = "cannot deliver from #{main}"
21
+ result[ :recovery ] = "git checkout -b <branch-name>"
22
+ return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
20
23
  end
21
24
 
22
25
  # Step 1: push the branch.
23
- push_result = push_branch!( branch: branch, remote: remote )
24
- return push_result unless push_result == EXIT_OK
26
+ push_exit = push_branch!( branch: branch, remote: remote, result: result )
27
+ return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
25
28
 
26
29
  # Step 2: find or create the PR.
27
30
  pr_number, pr_url = find_or_create_pr!(
28
- branch: branch, title: title, body_file: body_file
31
+ branch: branch, title: title, body_file: body_file, result: result
29
32
  )
30
- return EXIT_ERROR if pr_number.nil?
33
+ if pr_number.nil?
34
+ return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
35
+ end
31
36
 
32
- puts_line "PR: ##{pr_number} #{pr_url}"
37
+ result[ :pr_number ] = pr_number
38
+ result[ :pr_url ] = pr_url
33
39
 
34
40
  # Without --merge, we are done.
35
- return EXIT_OK unless merge
41
+ unless merge
42
+ return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
43
+ end
36
44
 
37
45
  # Step 3: check CI status.
38
46
  ci_status = check_pr_ci( number: pr_number )
47
+ result[ :ci ] = ci_status.to_s
48
+
39
49
  case ci_status
40
50
  when :pass
41
- puts_line "CI: pass"
51
+ # Continue to merge.
42
52
  when :pending
43
- puts_line "CI: pending merge when checks complete."
44
- return EXIT_OK
53
+ result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
54
+ return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
45
55
  when :fail
46
- puts_line "CI: failing — fix before merging."
47
- return EXIT_BLOCK
56
+ result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
57
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
48
58
  else
49
- puts_line "CI: unknown check manually."
50
- return EXIT_OK
59
+ result[ :recovery ] = "gh pr checks #{pr_number}"
60
+ return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
51
61
  end
52
62
 
53
63
  # Step 4: merge.
54
- merge_result = merge_pr!( number: pr_number )
55
- return merge_result unless merge_result == EXIT_OK
64
+ merge_exit = merge_pr!( number: pr_number, result: result )
65
+ return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
66
+
67
+ result[ :merged ] = true
56
68
 
57
69
  # Step 5: sync main.
58
70
  sync_after_merge!( remote: remote, main: main )
59
71
 
60
- EXIT_OK
72
+ deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
61
73
  end
62
74
 
63
75
  private
64
76
 
77
+ # Outputs the final result — JSON or human-readable — and returns exit code.
78
+ def deliver_finish( result:, exit_code:, json_output: )
79
+ result[ :exit_code ] = exit_code
80
+
81
+ if json_output
82
+ out.puts JSON.pretty_generate( result )
83
+ else
84
+ print_deliver_human( result: result )
85
+ end
86
+
87
+ exit_code
88
+ end
89
+
90
+ # Human-readable output for deliver results.
91
+ def print_deliver_human( result: )
92
+ exit_code = result.fetch( :exit_code )
93
+
94
+ if result[ :error ]
95
+ puts_line "ERROR: #{result[ :error ]}"
96
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
97
+ return
98
+ end
99
+
100
+ if result[ :pr_number ]
101
+ puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}"
102
+ end
103
+
104
+ if result[ :ci ]
105
+ ci = result[ :ci ]
106
+ case ci
107
+ when "pass"
108
+ puts_line "CI: pass"
109
+ when "pending"
110
+ puts_line "CI: pending — merge when checks complete."
111
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
112
+ when "fail"
113
+ puts_line "CI: failing — fix before merging."
114
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
115
+ else
116
+ puts_line "CI: #{ci} — check manually."
117
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
118
+ end
119
+ end
120
+
121
+ if result[ :merged ]
122
+ puts_line "Merged PR ##{result[ :pr_number ]} via #{result[ :merge_method ]}."
123
+ end
124
+ end
125
+
65
126
  # Pushes the branch to the remote with tracking.
66
- def push_branch!( branch:, remote: )
127
+ def push_branch!( branch:, remote:, result: )
67
128
  _, push_stderr, push_success, = git_run( "push", "-u", remote, branch )
68
129
  unless push_success
69
130
  error_text = push_stderr.to_s.strip
70
131
  error_text = "push failed" if error_text.empty?
71
- puts_line "ERROR: #{error_text}"
132
+ result[ :error ] = error_text
133
+ result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
72
134
  return EXIT_ERROR
73
135
  end
74
136
  puts_verbose "pushed #{branch} to #{remote}"
@@ -77,13 +139,13 @@ module Carson
77
139
 
78
140
  # Finds an existing PR for the branch, or creates a new one.
79
141
  # Returns [number, url] or [nil, nil] on failure.
80
- def find_or_create_pr!( branch:, title: nil, body_file: nil )
142
+ def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
81
143
  # Check for existing PR.
82
144
  existing = find_existing_pr( branch: branch )
83
145
  return existing if existing.first
84
146
 
85
147
  # Create a new PR.
86
- create_pr!( branch: branch, title: title, body_file: body_file )
148
+ create_pr!( branch: branch, title: title, body_file: body_file, result: result )
87
149
  end
88
150
 
89
151
  # Queries gh for an open PR on this branch.
@@ -104,7 +166,7 @@ module Carson
104
166
 
105
167
  # Creates a PR via gh. Title defaults to branch name humanised.
106
168
  # Returns [number, url] or [nil, nil] on failure.
107
- def create_pr!( branch:, title: nil, body_file: nil )
169
+ def create_pr!( branch:, title: nil, body_file: nil, result: )
108
170
  pr_title = title || default_pr_title( branch: branch )
109
171
 
110
172
  args = [ "pr", "create", "--title", pr_title, "--head", branch ]
@@ -118,7 +180,8 @@ module Carson
118
180
  unless success
119
181
  error_text = stderr.to_s.strip
120
182
  error_text = "pr create failed" if error_text.empty?
121
- puts_line "ERROR: #{error_text}"
183
+ result[ :error ] = error_text
184
+ result[ :recovery ] = "gh pr create --title '#{pr_title}' --head #{branch}"
122
185
  return [ nil, nil ]
123
186
  end
124
187
 
@@ -160,21 +223,23 @@ module Carson
160
223
  end
161
224
 
162
225
  # Merges the PR using the configured merge method.
163
- def merge_pr!( number: )
226
+ def merge_pr!( number:, result: )
164
227
  method = config.govern_merge_method
165
- stdout, stderr, success, = gh_run(
228
+ result[ :merge_method ] = method
229
+
230
+ _, stderr, success, = gh_run(
166
231
  "pr", "merge", number.to_s,
167
232
  "--#{method}",
168
233
  "--delete-branch"
169
234
  )
170
235
 
171
236
  if success
172
- puts_line "Merged PR ##{number} via #{method}."
173
237
  EXIT_OK
174
238
  else
175
239
  error_text = stderr.to_s.strip
176
240
  error_text = "merge failed" if error_text.empty?
177
- puts_line "ERROR: #{error_text}"
241
+ result[ :error ] = error_text
242
+ result[ :recovery ] = "gh pr merge #{number} --#{method} --delete-branch"
178
243
  EXIT_ERROR
179
244
  end
180
245
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang