carson 2.33.0 → 3.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: 83e70e6824d3de6da2d444aa9d0b2859f3710e357f87cdcf65cecc512ff63fee
4
- data.tar.gz: 6c09bd64199ab8ee6f7fa14722b670a123c7cb051ea2937c1e2bbfddb696fded
3
+ metadata.gz: 046c14a614687e668eae6d9f3df0eadbe02fcb103a1c93f25657791a0d72962c
4
+ data.tar.gz: 26ebca4203a0a9b3df1e38795580188bcf6951049304ae0eb4899deda35106f8
5
5
  SHA512:
6
- metadata.gz: 3a6570b397a73c27aab1d33e044bd28f56d1f643e57e76b40d3bbd5cdd1ff5da2779467980c9f595c6a25cbe2524cf5ff6508050a2736677ebb94079434eb749
7
- data.tar.gz: 33d29172314ab043a5a38122b91fac3c150b3d846ef113cff953fefec5f56f8c35af1290a48ef951fe1154de4caf749cd779daf98c09c939518c0e64f7a72ecd
6
+ metadata.gz: 38cb7ebf2e65293d5ea26bfb527314d86f7889949db033649fc49822b65feb7b7ea0e8fa5f35b6d959024ff4d280691432efde50dc15c37bf223665cd7d908b7
7
+ data.tar.gz: d05902ee1637919dd0a9781fa2de46e4370d2806df83c02c730584095f18811baea6168f3614d81900229dd5ec3c141ffb2ce6a40a8c338f233aa1a88a2a9b81
data/RELEASE.md CHANGED
@@ -5,6 +5,46 @@ 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.1.0 — Worktree Lifecycle
9
+
10
+ ### What changed
11
+
12
+ - **`carson worktree create <name>`** — creates a worktree under `.claude/worktrees/<name>` with a new branch based on main. One command, one result: path and branch name reported.
13
+ - **`carson worktree done <name>`** — marks a worktree as completed without deleting it. Verifies all changes are committed and pushed. Blocks with actionable guidance if uncommitted changes or unpushed commits exist. The worktree directory persists for batch cleanup later.
14
+ - **Deferred deletion model.** The full worktree lifecycle is now: create → work → done → batch cleanup. No worktree is deleted during an active session. Cleanup happens later via `carson worktree remove` or `carson housekeep`.
15
+
16
+ ### UX
17
+
18
+ - `carson worktree create` reports path and branch — ready to `cd` into immediately.
19
+ - `carson worktree done` gives recovery commands when changes are uncommitted or unpushed.
20
+ - Error messages across worktree subcommands now show `create|done|remove` in usage hints.
21
+
22
+ ### Migration
23
+
24
+ - No breaking changes. `carson worktree remove` continues to work as before.
25
+
26
+ ## 3.0.0 — Agent-Oriented Carson
27
+
28
+ ### Theme
29
+
30
+ Carson is for coding agents. The primary consumer of Carson's commands, lifecycle management, and governance is the coding agent working on behalf of the developer. Carson 3.0 reorients the product around this truth.
31
+
32
+ ### What changed
33
+
34
+ - **`carson status` — agent session briefing.** One command to know the full state of the estate: current branch and dirty/sync state, active worktrees, open PRs with CI and review status, stale branches ready for pruning, and governance health. Supports `--json` for machine-readable structured output — agents can parse the response directly instead of scraping human text.
35
+ - **Deferred worktree cleanup model.** Worktrees are no longer expected to be deleted immediately after use. The new lifecycle: create a worktree, do work, mark it done, clean up later in batch via `carson housekeep` or `carson prune`. This eliminates the #1 agent session crash: worktree directory disappearing while the agent's shell CWD is inside it.
36
+ - **`docs/agent-orient.md` — the agent's needs document.** Written from the coding agent's authentic perspective: what it experiences, what friction exists, and what it needs Carson to become. This document guides all 3.0 development.
37
+
38
+ ### UX
39
+
40
+ - `carson status` prints a concise briefing by default. Silence is preserved — status reports only what needs attention.
41
+ - `carson status --json` produces a stable JSON schema for programmatic consumption.
42
+
43
+ ### Migration
44
+
45
+ - No breaking changes. All 2.x commands continue to work unchanged.
46
+ - `docs/plan.md` has been removed — superseded by `docs/agent-orient.md`.
47
+
8
48
  ## 2.33.0 — Safe Worktree Remove
9
49
 
10
50
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.33.0
1
+ 3.1.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 [setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]|audit|sync|prune [--all]|worktree remove <name-or-path>|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit|sync|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 "status"
92
+ parse_status_command( argv: argv, err: err )
91
93
  when "govern"
92
94
  parse_govern_subcommand( argv: argv, err: err )
93
95
  else
@@ -168,12 +170,22 @@ module Carson
168
170
  def self.parse_worktree_subcommand( argv:, parser:, err: )
169
171
  action = argv.shift
170
172
  if action.to_s.strip.empty?
171
- err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree remove <name-or-path>"
173
+ err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
172
174
  err.puts parser
173
175
  return { command: :invalid }
174
176
  end
175
177
 
176
178
  case action
179
+ when "create"
180
+ name = argv.shift
181
+ if name.to_s.strip.empty?
182
+ err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
183
+ return { command: :invalid }
184
+ end
185
+ { command: "worktree:create", worktree_name: name }
186
+ when "done"
187
+ name = argv.shift
188
+ { command: "worktree:done", worktree_name: name }
177
189
  when "remove"
178
190
  force = argv.delete( "--force" ) ? true : false
179
191
  worktree_path = argv.shift
@@ -183,7 +195,7 @@ module Carson
183
195
  end
184
196
  { command: "worktree:remove", worktree_path: worktree_path, force: force }
185
197
  else
186
- err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree remove <name-or-path>"
198
+ err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
187
199
  { command: :invalid }
188
200
  end
189
201
  end
@@ -228,6 +240,15 @@ module Carson
228
240
  { command: :invalid }
229
241
  end
230
242
 
243
+ def self.parse_status_command( argv:, err: )
244
+ json_flag = argv.delete( "--json" ) ? true : false
245
+ unless argv.empty?
246
+ err.puts "#{BADGE} Unexpected arguments for status: #{argv.join( ' ' )}"
247
+ return { command: :invalid }
248
+ end
249
+ { command: "status", json: json_flag }
250
+ end
251
+
231
252
  def self.parse_govern_subcommand( argv:, err: )
232
253
  options = {
233
254
  dry_run: false,
@@ -266,6 +287,8 @@ module Carson
266
287
  return Runtime::EXIT_ERROR if command == :invalid
267
288
 
268
289
  case command
290
+ when "status"
291
+ runtime.status!( json_output: parsed.fetch( :json, false ) )
269
292
  when "setup"
270
293
  runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
271
294
  when "audit"
@@ -276,6 +299,10 @@ module Carson
276
299
  runtime.prune!
277
300
  when "prune:all"
278
301
  runtime.prune_all!
302
+ when "worktree:create"
303
+ runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
304
+ when "worktree:done"
305
+ runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ) )
279
306
  when "worktree:remove"
280
307
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
281
308
  when "onboard"
@@ -2,7 +2,83 @@ module Carson
2
2
  class Runtime
3
3
  module Local
4
4
  # Safe worktree lifecycle management for coding agents.
5
- # Enforces the teardown order: exit worktree git worktree remove branch cleanup.
5
+ # Three operations: create, done (mark completed), remove (batch cleanup).
6
+ # The deferred deletion model: worktrees persist after use, cleaned up later.
7
+
8
+ # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
9
+ def worktree_create!( name: )
10
+ worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
11
+ wt_path = File.join( worktrees_dir, name )
12
+
13
+ if Dir.exist?( wt_path )
14
+ puts_line "ERROR: worktree already exists: #{name}"
15
+ puts_line " Path: #{wt_path}"
16
+ return EXIT_ERROR
17
+ end
18
+
19
+ # Determine the base branch (main branch from config).
20
+ base = config.main_branch
21
+
22
+ # Create the worktree with a new branch based on the main branch.
23
+ FileUtils.mkdir_p( worktrees_dir )
24
+ _, wt_stderr, wt_success, = git_run( "worktree", "add", wt_path, "-b", name, base )
25
+ unless wt_success
26
+ error_text = wt_stderr.to_s.strip
27
+ error_text = "unable to create worktree" if error_text.empty?
28
+ puts_line "ERROR: #{error_text}"
29
+ return EXIT_ERROR
30
+ end
31
+
32
+ puts_line "Worktree created: #{name}"
33
+ puts_line " Path: #{wt_path}"
34
+ puts_line " Branch: #{name}"
35
+ EXIT_OK
36
+ end
37
+
38
+ # Marks a worktree as completed without deleting it.
39
+ # Verifies all changes are committed. Deferred deletion — cleanup happens later.
40
+ def worktree_done!( name: nil )
41
+ if name.to_s.strip.empty?
42
+ # Try to detect current worktree from CWD.
43
+ puts_line "ERROR: missing worktree name. Use: carson worktree done <name>"
44
+ return EXIT_ERROR
45
+ end
46
+
47
+ resolved_path = resolve_worktree_path( worktree_path: name )
48
+
49
+ unless worktree_registered?( path: resolved_path )
50
+ puts_line "ERROR: #{name} is not a registered worktree."
51
+ return EXIT_ERROR
52
+ end
53
+
54
+ # Check for uncommitted changes in the worktree.
55
+ wt_status, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
56
+ if status_success && !wt_status.strip.empty?
57
+ puts_line "Worktree has uncommitted changes: #{name}"
58
+ puts_line " Commit your changes first, then run `carson worktree done #{name}` again."
59
+ return EXIT_BLOCK
60
+ end
61
+
62
+ # Check for unpushed commits.
63
+ branch = worktree_branch( path: resolved_path )
64
+ if branch
65
+ remote = config.git_remote
66
+ remote_ref = "#{remote}/#{branch}"
67
+ ahead, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
68
+ if ahead_ok && ahead.strip.to_i > 0
69
+ puts_line "Worktree has unpushed commits: #{name}"
70
+ puts_line " Push with `git -C #{resolved_path} push #{remote} #{branch}` first."
71
+ return EXIT_BLOCK
72
+ end
73
+ end
74
+
75
+ puts_line "Worktree done: #{name}"
76
+ puts_line " Branch: #{branch || '(detached)'}"
77
+ puts_line " Cleanup later with `carson worktree remove #{name}` or `carson housekeep`."
78
+ EXIT_OK
79
+ end
80
+
81
+ # Removes a worktree: directory, git registration, and branch.
6
82
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
7
83
  # the user explicitly passes force: true via CLI --force flag.
8
84
  def worktree_remove!( worktree_path:, force: false )
@@ -0,0 +1,229 @@
1
+ # Agent session briefing — one command to know the full state of the estate.
2
+ # Gathers branch, working tree, worktrees, open PRs, stale branches,
3
+ # governance health, and version. Supports human-readable and JSON output.
4
+ module Carson
5
+ class Runtime
6
+ module Status
7
+ # Entry point for `carson status`. Collects estate state and reports.
8
+ def status!( json_output: false )
9
+ data = gather_status
10
+
11
+ if json_output
12
+ out.puts JSON.pretty_generate( data )
13
+ else
14
+ print_status( data: data )
15
+ end
16
+
17
+ EXIT_OK
18
+ end
19
+
20
+ private
21
+
22
+ # Collects all status facets into a structured hash.
23
+ def gather_status
24
+ data = {
25
+ version: Carson::VERSION,
26
+ branch: gather_branch_info,
27
+ worktrees: gather_worktree_info,
28
+ governance: gather_governance_info
29
+ }
30
+
31
+ # PR and stale branch data require gh — gather with graceful fallback.
32
+ if gh_available?
33
+ data[ :pull_requests ] = gather_pr_info
34
+ data[ :stale_branches ] = gather_stale_branch_info
35
+ end
36
+
37
+ data
38
+ end
39
+
40
+ # Branch name, clean/dirty state, sync status with remote.
41
+ def gather_branch_info
42
+ branch = current_branch
43
+ dirty = working_tree_dirty?
44
+ sync = remote_sync_status( branch: branch )
45
+
46
+ { name: branch, dirty: dirty, sync: sync }
47
+ end
48
+
49
+ # Returns true when the working tree has uncommitted changes.
50
+ def working_tree_dirty?
51
+ stdout, _, success, = git_run( "status", "--porcelain" )
52
+ return true unless success
53
+ !stdout.strip.empty?
54
+ end
55
+
56
+ # Compares local branch against its remote tracking ref.
57
+ # Returns :in_sync, :ahead, :behind, :diverged, or :no_remote.
58
+ def remote_sync_status( branch: )
59
+ remote = config.git_remote
60
+ remote_ref = "#{remote}/#{branch}"
61
+
62
+ # Check if the remote ref exists.
63
+ _, _, exists, = git_run( "rev-parse", "--verify", remote_ref )
64
+ return :no_remote unless exists
65
+
66
+ ahead_behind, _, success, = git_run( "rev-list", "--left-right", "--count", "#{branch}...#{remote_ref}" )
67
+ return :unknown unless success
68
+
69
+ parts = ahead_behind.strip.split( /\s+/ )
70
+ ahead = parts[ 0 ].to_i
71
+ behind = parts[ 1 ].to_i
72
+
73
+ if ahead.zero? && behind.zero?
74
+ :in_sync
75
+ elsif ahead.positive? && behind.zero?
76
+ :ahead
77
+ elsif ahead.zero? && behind.positive?
78
+ :behind
79
+ else
80
+ :diverged
81
+ end
82
+ end
83
+
84
+ # Lists all worktrees with branch and lifecycle state.
85
+ def gather_worktree_info
86
+ entries = worktree_list
87
+ # Filter out the main worktree (the repository root itself).
88
+ entries.reject { |wt| wt.fetch( :path ) == repo_root }.map do |wt|
89
+ {
90
+ path: wt.fetch( :path ),
91
+ name: File.basename( wt.fetch( :path ) ),
92
+ branch: wt.fetch( :branch, nil )
93
+ }
94
+ end
95
+ end
96
+
97
+ # Queries open PRs via gh.
98
+ def gather_pr_info
99
+ stdout, _, success, = gh_run(
100
+ "pr", "list", "--state", "open",
101
+ "--json", "number,title,headRefName,statusCheckRollup,reviewDecision"
102
+ )
103
+ return [] unless success
104
+
105
+ prs = JSON.parse( stdout ) rescue []
106
+ prs.map do |pr|
107
+ ci = summarise_checks( rollup: pr[ "statusCheckRollup" ] )
108
+ review = pr[ "reviewDecision" ].to_s
109
+ review_label = review_decision_label( decision: review )
110
+
111
+ {
112
+ number: pr[ "number" ],
113
+ title: pr[ "title" ],
114
+ branch: pr[ "headRefName" ],
115
+ ci: ci,
116
+ review: review_label
117
+ }
118
+ end
119
+ end
120
+
121
+ # Summarises check rollup into a single status word.
122
+ def summarise_checks( rollup: )
123
+ entries = Array( rollup )
124
+ return :none if entries.empty?
125
+
126
+ states = entries.map { |c| c[ "conclusion" ].to_s.upcase }
127
+ return :fail if states.any? { |s| s == "FAILURE" || s == "CANCELLED" || s == "TIMED_OUT" }
128
+ return :pending if states.any? { |s| s == "" || s == "PENDING" || s == "QUEUED" || s == "IN_PROGRESS" }
129
+
130
+ :pass
131
+ end
132
+
133
+ # Translates GitHub review decision to a concise label.
134
+ def review_decision_label( decision: )
135
+ case decision.upcase
136
+ when "APPROVED" then :approved
137
+ when "CHANGES_REQUESTED" then :changes_requested
138
+ when "REVIEW_REQUIRED" then :review_required
139
+ else :none
140
+ end
141
+ end
142
+
143
+ # Counts local branches that are stale (tracking a deleted upstream).
144
+ def gather_stale_branch_info
145
+ stdout, _, success, = git_run( "branch", "-vv" )
146
+ return { count: 0 } unless success
147
+
148
+ gone_branches = stdout.lines.select { |l| l.include?( ": gone]" ) }
149
+ { count: gone_branches.size }
150
+ end
151
+
152
+ # Quick governance health check: are templates in sync?
153
+ def gather_governance_info
154
+ result = with_captured_output { template_check! }
155
+ {
156
+ templates: result == EXIT_OK ? :in_sync : :drifted
157
+ }
158
+ rescue StandardError
159
+ { templates: :unknown }
160
+ end
161
+
162
+ # Prints the human-readable status report.
163
+ def print_status( data: )
164
+ puts_line "Carson #{data.fetch( :version )}"
165
+ puts_line ""
166
+
167
+ # Branch
168
+ branch = data.fetch( :branch )
169
+ dirty_marker = branch.fetch( :dirty ) ? " (dirty)" : ""
170
+ sync_marker = format_sync( sync: branch.fetch( :sync ) )
171
+ puts_line "Branch: #{branch.fetch( :name )}#{dirty_marker}#{sync_marker}"
172
+
173
+ # Worktrees
174
+ worktrees = data.fetch( :worktrees )
175
+ if worktrees.any?
176
+ puts_line ""
177
+ puts_line "Worktrees:"
178
+ worktrees.each do |wt|
179
+ branch_label = wt.fetch( :branch ) || "(detached)"
180
+ puts_line " #{wt.fetch( :name )} #{branch_label}"
181
+ end
182
+ end
183
+
184
+ # Pull requests
185
+ prs = data.fetch( :pull_requests, nil )
186
+ if prs && prs.any?
187
+ puts_line ""
188
+ puts_line "Pull requests:"
189
+ prs.each do |pr|
190
+ ci_label = pr.fetch( :ci ).to_s
191
+ review_label = pr.fetch( :review ).to_s.tr( "_", " " )
192
+ puts_line " ##{pr.fetch( :number )} #{pr.fetch( :title )}"
193
+ puts_line " CI: #{ci_label} Review: #{review_label}"
194
+ end
195
+ end
196
+
197
+ # Stale branches
198
+ stale = data.fetch( :stale_branches, nil )
199
+ if stale && stale.fetch( :count ) > 0
200
+ count = stale.fetch( :count )
201
+ puts_line ""
202
+ puts_line "#{count} stale branch#{plural_suffix( count: count )} ready for pruning."
203
+ end
204
+
205
+ # Governance
206
+ gov = data.fetch( :governance )
207
+ templates = gov.fetch( :templates )
208
+ unless templates == :in_sync
209
+ puts_line ""
210
+ puts_line "Templates: #{templates} — run `carson sync` to fix."
211
+ end
212
+ end
213
+
214
+ # Formats sync status for display.
215
+ def format_sync( sync: )
216
+ case sync
217
+ when :in_sync then ""
218
+ when :ahead then " (ahead of remote)"
219
+ when :behind then " (behind remote)"
220
+ when :diverged then " (diverged from remote)"
221
+ when :no_remote then " (no remote tracking)"
222
+ else ""
223
+ end
224
+ end
225
+ end
226
+
227
+ include Status
228
+ end
229
+ end
@@ -220,3 +220,4 @@ require_relative "runtime/audit"
220
220
  require_relative "runtime/review"
221
221
  require_relative "runtime/govern"
222
222
  require_relative "runtime/setup"
223
+ require_relative "runtime/status"
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: 2.33.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -67,6 +67,7 @@ files:
67
67
  - lib/carson/runtime/review/sweep_support.rb
68
68
  - lib/carson/runtime/review/utility.rb
69
69
  - lib/carson/runtime/setup.rb
70
+ - lib/carson/runtime/status.rb
70
71
  - lib/carson/version.rb
71
72
  - templates/.github/AGENTS.md
72
73
  - templates/.github/CLAUDE.md