carson 3.11.0 → 3.13.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: 0d6a7526cb4973b9f911b8fd28d151ca719cbb53be7d42beb29e676d0d9354f4
4
- data.tar.gz: 4b116460df0e2e9397edf0f60432abb907d7b81f1e28a4d7e8bceda47e030db7
3
+ metadata.gz: 1d653b3c6ec67aa729186db46215c6e51c85a42ac82ca782f5004f9d84e32a31
4
+ data.tar.gz: 5caa70de1a5c9538c3ef3027ba0862445c9ff2d985ec6f4659231bb40d8e5cb7
5
5
  SHA512:
6
- metadata.gz: 807185f0bbeb5c2762b89c8a59aed681006a1589a66024f2cf546972416bde79ad858be69cceb685a6b762a8594d95efa41607323f1d5469f3d70709b64a57f1
7
- data.tar.gz: 7d7c6a06a7bf4b75428a57afa891f04dadf1ca402ac35cfee2d968658963e180b8b2ed27a1aa2a8c78c057456d319977ddfce6d022f945281081c9d7b936a7ff
6
+ metadata.gz: 91bd6ee521d91f31c3593558e1b544fcba99eefd1d084fb8c618fad428c470d308ed774e7655b31be9d0fc35969bd9d5354832d5002042396e3d48b8ffde77b2
7
+ data.tar.gz: 10758df9aeb5d246211592671eb19c59156cbd9fd7cb5d6e31e32186ee36d1caef8fe3dc7c785bdbfbac4e1339ee8d56bf445556a6722395f8eb42a2bfdabe12
data/RELEASE.md CHANGED
@@ -5,11 +5,36 @@ 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.13.0
9
+
10
+ ### What changed
11
+
12
+ - **Worktree create auto-syncs main** — `carson worktree create` now pulls the main branch from remote (`--ff-only`) before branching. Prevents stale-base merge conflicts that waste agent context resolving later. Best-effort: if pull fails (offline, non-fast-forward), creation continues from the local main.
13
+ - **Deliver prints next steps after merge** — `carson deliver --merge` now tells the agent exactly what to do after a successful merge. If running inside a worktree, prints `cd <main_root> && carson worktree remove <name>`. If not, suggests `carson prune`. Available in both human and JSON output (`next_step` field).
14
+
15
+ ### UX improvement
16
+
17
+ - Agents no longer need to remember post-merge cleanup steps — Carson tells them.
18
+ - Agents no longer hit merge conflicts from stale main — Carson syncs before branching.
19
+
20
+ ## 3.12.0
21
+
22
+ ### What changed
23
+
24
+ - **Drop session state** — removed the `session` and `session clear` CLI commands, session file persistence (`~/.carson/sessions/`), and all session side effects from `worktree create`, `worktree remove`, and `deliver`. Session state duplicated information already available from better sources (`git worktree list`, `gh pr list`, memory files). No agent ever read another agent's session file. Convention ("don't touch other sessions' worktrees") beat engineered tracking.
25
+ - **Simplify status** — `carson status` no longer shows session ownership annotations on worktrees. Worktree display now shows name and branch only — the same information `git worktree list` provides.
26
+
27
+ ### Breaking changes
28
+
29
+ - `carson session` and `carson session clear` no longer exist.
30
+ - Session files in `~/.carson/sessions/` are no longer written or read. Existing files are inert.
31
+ - `carson status` worktree entries no longer include `owner`, `owner_pid`, `owner_task`, or `stale` fields in JSON output.
32
+
8
33
  ## 3.11.0
9
34
 
10
35
  ### What changed
11
36
 
12
- - **Drop `worktree done`** — removed the `worktree done` subcommand entirely. `worktree remove` now handles everything: CWD safety guard, unpushed-commits guard, session state cleanup, branch and remote deletion. Two operations (create, remove) instead of three. Simpler, safer, less to remember.
37
+ - **Drop `worktree done`** — removed the `worktree done` subcommand entirely. `worktree remove` now handles everything: CWD safety guard, unpushed-commits guard, branch and remote deletion. Two operations (create, remove) instead of three. Simpler, safer, less to remember.
13
38
 
14
39
  ### Breaking changes
15
40
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.11.0
1
+ 3.13.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 [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|session [--json] [--task T]|session clear [--json]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|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
 
@@ -98,8 +98,6 @@ module Carson
98
98
  parse_deliver_command( argv: argv, err: err )
99
99
  when "govern"
100
100
  parse_govern_subcommand( argv: argv, err: err )
101
- when "session"
102
- parse_session_command( argv: argv, err: err )
103
101
  else
104
102
  parser.parse!( argv )
105
103
  { command: command }
@@ -334,29 +332,6 @@ module Carson
334
332
  { command: :invalid }
335
333
  end
336
334
 
337
- def self.parse_session_command( argv:, err: )
338
- json_flag = argv.delete( "--json" ) ? true : false
339
- task_value = nil
340
- # Check for --task "description"
341
- task_index = argv.index( "--task" )
342
- if task_index
343
- argv.delete_at( task_index )
344
- task_value = argv.delete_at( task_index )
345
- if task_value.to_s.strip.empty?
346
- err.puts "#{BADGE} Missing value for --task. Use: carson session --task \"description\""
347
- return { command: :invalid }
348
- end
349
- end
350
-
351
- action = argv.first
352
- if action == "clear"
353
- argv.shift
354
- return { command: "session:clear", json: json_flag }
355
- end
356
-
357
- { command: "session", json: json_flag, task: task_value }
358
- end
359
-
360
335
  def self.dispatch( parsed:, runtime: )
361
336
  command = parsed.fetch( :command )
362
337
  return Runtime::EXIT_ERROR if command == :invalid
@@ -407,13 +382,6 @@ module Carson
407
382
  json_output: parsed.fetch( :json, false ),
408
383
  loop_seconds: parsed.fetch( :loop_seconds, nil )
409
384
  )
410
- when "session"
411
- runtime.session!(
412
- task: parsed.fetch( :task, nil ),
413
- json_output: parsed.fetch( :json, false )
414
- )
415
- when "session:clear"
416
- runtime.session_clear!( json_output: parsed.fetch( :json, false ) )
417
385
  else
418
386
  runtime.send( :puts_line, "Unknown command: #{command}" )
419
387
  Runtime::EXIT_ERROR
@@ -36,10 +36,6 @@ module Carson
36
36
 
37
37
  result[ :pr_number ] = pr_number
38
38
  result[ :pr_url ] = pr_url
39
-
40
- # Record PR in session state.
41
- update_session( pr: { number: pr_number, url: pr_url } )
42
-
43
39
  # Without --merge, we are done.
44
40
  unless merge
45
41
  return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
@@ -78,12 +74,12 @@ module Carson
78
74
 
79
75
  result[ :merged ] = true
80
76
 
81
- # Step 6: clear worktree from session state.
82
- update_session( worktree: :clear )
83
-
84
- # Step 7: sync main in the main worktree.
77
+ # Step 6: sync main in the main worktree.
85
78
  sync_after_merge!( remote: remote, main: main, result: result )
86
79
 
80
+ # Step 7: compute next-step guidance for the agent.
81
+ compute_post_merge_next_step!( result: result )
82
+
87
83
  deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
88
84
  end
89
85
 
@@ -135,6 +131,7 @@ module Carson
135
131
 
136
132
  if result[ :merged ]
137
133
  puts_line "Merged PR ##{result[ :pr_number ]} via #{result[ :merge_method ]}."
134
+ puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
138
135
  end
139
136
  end
140
137
 
@@ -297,6 +294,24 @@ module Carson
297
294
  puts_verbose "sync failed: #{pull_stderr.to_s.strip}"
298
295
  end
299
296
  end
297
+
298
+ # Builds next-step guidance after a successful merge.
299
+ # Detects whether the agent is inside a worktree and suggests cleanup.
300
+ def compute_post_merge_next_step!( result: )
301
+ main_root = main_worktree_root
302
+ cwd = realpath_safe( Dir.pwd )
303
+ current_wt = worktree_list.select { |wt| wt.fetch( :path ) != realpath_safe( main_root ) }
304
+ .find { |wt| cwd == wt.fetch( :path ) || cwd.start_with?( File.join( wt.fetch( :path ), "" ) ) }
305
+
306
+ if current_wt
307
+ wt_name = File.basename( current_wt.fetch( :path ) )
308
+ result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
309
+ else
310
+ result[ :next_step ] = "carson prune"
311
+ end
312
+ rescue StandardError
313
+ # Best-effort — do not fail deliver because of next-step detection.
314
+ end
300
315
  end
301
316
 
302
317
  include Deliver
@@ -24,6 +24,13 @@ module Carson
24
24
  # Determine the base branch (main branch from config).
25
25
  base = config.main_branch
26
26
 
27
+ # Sync main from remote before branching so the worktree starts
28
+ # from the latest code. Prevents stale-base merge conflicts later.
29
+ # Best-effort — if pull fails (non-ff, offline), continue anyway.
30
+ main_root = main_worktree_root
31
+ _, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", config.git_remote, base )
32
+ puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"
33
+
27
34
  # Ensure .claude/ is excluded from git status in the host repository.
28
35
  # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
29
36
  ensure_claude_dir_excluded!
@@ -41,9 +48,6 @@ module Carson
41
48
  )
42
49
  end
43
50
 
44
- # Record active worktree in session state.
45
- update_session( worktree: { name: name, path: wt_path, branch: name } )
46
-
47
51
  worktree_finish(
48
52
  result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
49
53
  exit_code: EXIT_OK, json_output: json_output
@@ -154,10 +158,6 @@ module Carson
154
158
  remote_deleted = true
155
159
  end
156
160
  end
157
-
158
- # Clear worktree from session state.
159
- update_session( worktree: :clear )
160
-
161
161
  worktree_finish(
162
162
  result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
163
163
  branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
@@ -1,5 +1,5 @@
1
- # Agent session briefing — one command to know the full state of the estate.
2
- # Gathers branch, working tree, worktrees, open PRs, stale branches,
1
+ # Agent briefing — one command to know the full state of the estate.
2
+ # Gathers branch, worktrees, open PRs, stale branches,
3
3
  # governance health, and version. Supports human-readable and JSON output.
4
4
  module Carson
5
5
  class Runtime
@@ -81,49 +81,20 @@ module Carson
81
81
  end
82
82
  end
83
83
 
84
- # Lists all worktrees with branch, lifecycle state, and session ownership.
84
+ # Lists all worktrees with branch name.
85
85
  def gather_worktree_info
86
86
  entries = worktree_list
87
- sessions = session_list
88
- ownership = build_worktree_ownership( sessions: sessions )
89
87
 
90
88
  # Filter out the main worktree (the repository root itself).
91
89
  # Use realpath for comparison — git returns canonical paths that may differ from repo_root.
92
90
  canonical_root = realpath_safe( repo_root )
93
91
  entries.reject { |wt| wt.fetch( :path ) == canonical_root }.map do |wt|
94
- name = File.basename( wt.fetch( :path ) )
95
- info = {
92
+ {
96
93
  path: wt.fetch( :path ),
97
- name: name,
94
+ name: File.basename( wt.fetch( :path ) ),
98
95
  branch: wt.fetch( :branch, nil )
99
96
  }
100
- owner = ownership[ name ]
101
- if owner
102
- info[ :owner ] = owner[ :session_id ]
103
- info[ :owner_pid ] = owner[ :pid ]
104
- info[ :owner_task ] = owner[ :task ]
105
- info[ :stale ] = owner[ :stale ]
106
- end
107
- info
108
- end
109
- end
110
-
111
- # Builds a name-to-session mapping for worktree ownership.
112
- def build_worktree_ownership( sessions: )
113
- result = {}
114
- sessions.each do |session|
115
- wt = session[ :worktree ]
116
- next unless wt
117
- name = wt[ :name ] || wt[ "name" ]
118
- next unless name
119
- result[ name ] = {
120
- session_id: session[ :session_id ] || session[ "session_id" ],
121
- pid: session[ :pid ] || session[ "pid" ],
122
- task: session[ :task ] || session[ "task" ],
123
- stale: session[ :stale ]
124
- }
125
97
  end
126
- result
127
98
  end
128
99
 
129
100
  # Queries open PRs via gh.
@@ -209,8 +180,7 @@ module Carson
209
180
  puts_line "Worktrees:"
210
181
  worktrees.each do |wt|
211
182
  branch_label = wt.fetch( :branch ) || "(detached)"
212
- owner_label = format_worktree_owner( worktree: wt )
213
- puts_line " #{wt.fetch( :name )} #{branch_label}#{owner_label}"
183
+ puts_line " #{wt.fetch( :name )} #{branch_label}"
214
184
  end
215
185
  end
216
186
 
@@ -244,24 +214,6 @@ module Carson
244
214
  end
245
215
  end
246
216
 
247
- # Formats owner annotation for a worktree entry.
248
- def format_worktree_owner( worktree: )
249
- owner = worktree[ :owner ]
250
- return "" unless owner
251
-
252
- stale = worktree[ :stale ]
253
- task = worktree[ :owner_task ]
254
- pid = worktree[ :owner_pid ]
255
-
256
- if stale
257
- " (stale session #{pid})"
258
- elsif task
259
- " (#{task})"
260
- else
261
- " (session #{pid})"
262
- end
263
- end
264
-
265
217
  # Formats sync status for display.
266
218
  def format_sync( sync: )
267
219
  case sync
@@ -222,4 +222,3 @@ require_relative "runtime/govern"
222
222
  require_relative "runtime/setup"
223
223
  require_relative "runtime/status"
224
224
  require_relative "runtime/deliver"
225
- require_relative "runtime/session"
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.11.0
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -67,7 +67,6 @@ files:
67
67
  - lib/carson/runtime/review/query_text.rb
68
68
  - lib/carson/runtime/review/sweep_support.rb
69
69
  - lib/carson/runtime/review/utility.rb
70
- - lib/carson/runtime/session.rb
71
70
  - lib/carson/runtime/setup.rb
72
71
  - lib/carson/runtime/status.rb
73
72
  - lib/carson/version.rb
@@ -1,228 +0,0 @@
1
- # Session state persistence for coding agents.
2
- # Maintains a lightweight JSON file per session in ~/.carson/sessions/<repo_slug>/
3
- # so agents can discover the current working context without re-running discovery commands.
4
- # Multiple agents on the same repo each get their own session file.
5
- # Respects the outsider boundary: state lives in Carson's own space, not the repository.
6
- require "digest"
7
-
8
- module Carson
9
- class Runtime
10
- module Session
11
- # Reads and displays current session state for this repository.
12
- def session!( task: nil, json_output: false )
13
- if task
14
- update_session( worktree: nil, pr: nil, task: task )
15
- state = read_session
16
- return session_finish(
17
- result: state.merge( command: "session", status: "ok" ),
18
- exit_code: EXIT_OK, json_output: json_output
19
- )
20
- end
21
-
22
- state = read_session
23
- session_finish(
24
- result: state.merge( command: "session", status: "ok" ),
25
- exit_code: EXIT_OK, json_output: json_output
26
- )
27
- end
28
-
29
- # Clears session state for the current session.
30
- def session_clear!( json_output: false )
31
- path = session_file_path
32
- File.delete( path ) if File.exist?( path )
33
- session_finish(
34
- result: { command: "session clear", status: "ok", repo: repo_root },
35
- exit_code: EXIT_OK, json_output: json_output
36
- )
37
- end
38
-
39
- # Records session state — called as a side effect from other commands.
40
- # Only non-nil values are updated; nil values preserve existing state.
41
- def update_session( worktree: nil, pr: nil, task: nil )
42
- state = read_session
43
-
44
- if worktree == :clear
45
- state.delete( :worktree )
46
- elsif worktree
47
- state[ :worktree ] = worktree
48
- end
49
-
50
- if pr == :clear
51
- state.delete( :pr )
52
- elsif pr
53
- state[ :pr ] = pr
54
- end
55
-
56
- state[ :task ] = task if task
57
- state[ :repo ] = repo_root
58
- state[ :session_id ] = session_id
59
- state[ :pid ] = Process.pid
60
- state[ :updated_at ] = Time.now.utc.iso8601
61
-
62
- write_session( state )
63
- end
64
-
65
- # Returns all active sessions for this repository.
66
- # Each entry is a parsed session state hash with staleness annotation.
67
- def session_list
68
- dir = session_repo_dir
69
- return [] unless Dir.exist?( dir )
70
-
71
- Dir.glob( File.join( dir, "*.json" ) ).filter_map do |path|
72
- data = JSON.parse( File.read( path ), symbolize_names: true ) rescue next
73
- data[ :stale ] = session_stale?( data )
74
- data
75
- end
76
- end
77
-
78
- private
79
-
80
- # Returns a stable session identifier for this Runtime instance.
81
- # PID + start timestamp — unique per process, stable across calls.
82
- def session_id
83
- @session_id ||= "#{Process.pid}-#{Time.now.utc.strftime( '%Y%m%d%H%M%S' )}"
84
- end
85
-
86
- # Returns the per-repo session directory: ~/.carson/sessions/<slug>/
87
- # Migrates from the pre-3.9 single-file format if found.
88
- def session_repo_dir
89
- slug = session_repo_slug
90
- dir = File.join( carson_home, "sessions", slug )
91
-
92
- # Migrate from pre-3.9 single-file format: <slug>.json → <slug>/<session_id>.json
93
- old_file = File.join( carson_home, "sessions", "#{slug}.json" )
94
- if File.file?( old_file ) && !Dir.exist?( dir )
95
- FileUtils.mkdir_p( dir )
96
- migrated = File.join( dir, "migrated.json" )
97
- FileUtils.mv( old_file, migrated )
98
- end
99
-
100
- FileUtils.mkdir_p( dir )
101
- dir
102
- end
103
-
104
- # Returns the session file path for the current session.
105
- def session_file_path
106
- File.join( session_repo_dir, "#{session_id}.json" )
107
- end
108
-
109
- # Generates a readable, unique slug for the repository: basename-shortsha.
110
- def session_repo_slug
111
- basename = File.basename( repo_root )
112
- short_hash = Digest::SHA256.hexdigest( repo_root )[ 0, 8 ]
113
- "#{basename}-#{short_hash}"
114
- end
115
-
116
- # Returns Carson's home directory (~/.carson).
117
- def carson_home
118
- home = ENV.fetch( "HOME", "" ).to_s
119
- return File.join( home, ".carson" ) if !home.empty? && home.start_with?( "/" )
120
-
121
- File.join( "/tmp", ".carson" )
122
- end
123
-
124
- # Reads session state from disk. Returns an empty hash if no state exists.
125
- def read_session
126
- path = session_file_path
127
- return { repo: repo_root, session_id: session_id } unless File.exist?( path )
128
-
129
- data = JSON.parse( File.read( path ), symbolize_names: true )
130
- data[ :repo ] = repo_root
131
- data[ :session_id ] = session_id
132
- data
133
- rescue JSON::ParserError, StandardError
134
- { repo: repo_root, session_id: session_id }
135
- end
136
-
137
- # Writes session state to disk as formatted JSON.
138
- def write_session( state )
139
- path = session_file_path
140
- # Convert symbol keys to strings for clean JSON output.
141
- string_state = deep_stringify_keys( state )
142
- File.write( path, JSON.pretty_generate( string_state ) + "\n" )
143
- end
144
-
145
- # Recursively converts symbol keys to strings for JSON serialisation.
146
- def deep_stringify_keys( hash )
147
- hash.each_with_object( {} ) do |( key, value ), result|
148
- string_key = key.to_s
149
- result[ string_key ] = value.is_a?( Hash ) ? deep_stringify_keys( value ) : value
150
- end
151
- end
152
-
153
- # Detects whether a session is stale — PID no longer running and updated
154
- # more than 1 hour ago.
155
- def session_stale?( data )
156
- pid = data[ :pid ]
157
- updated = data[ :updated_at ]
158
-
159
- # If PID is still running, not stale.
160
- if pid
161
- begin
162
- Process.kill( 0, pid.to_i )
163
- return false
164
- rescue Errno::ESRCH
165
- # Process not running — check age.
166
- rescue Errno::EPERM
167
- # Process exists but we lack permission — assume active.
168
- return false
169
- end
170
- end
171
-
172
- # If no timestamp, assume stale.
173
- return true unless updated
174
-
175
- # Stale if last updated more than 1 hour ago.
176
- age = Time.now.utc - Time.parse( updated )
177
- age > 3600
178
- rescue StandardError
179
- true
180
- end
181
-
182
- # Unified output for session results — JSON or human-readable.
183
- def session_finish( result:, exit_code:, json_output: )
184
- result[ :exit_code ] = exit_code
185
-
186
- if json_output
187
- out.puts JSON.pretty_generate( result )
188
- else
189
- print_session_human( result: result )
190
- end
191
-
192
- exit_code
193
- end
194
-
195
- # Human-readable output for session state.
196
- def print_session_human( result: )
197
- if result[ :command ] == "session clear"
198
- puts_line "Session state cleared."
199
- return
200
- end
201
-
202
- puts_line "Session: #{File.basename( result[ :repo ].to_s )}"
203
-
204
- if result[ :worktree ]
205
- wt = result[ :worktree ]
206
- puts_line " Worktree: #{wt[ :name ] || wt[ "name" ]} (#{wt[ :branch ] || wt[ "branch" ]})"
207
- end
208
-
209
- if result[ :pr ]
210
- pr = result[ :pr ]
211
- puts_line " PR: ##{pr[ :number ] || pr[ "number" ]} #{pr[ :url ] || pr[ "url" ]}"
212
- end
213
-
214
- if result[ :task ]
215
- puts_line " Task: #{result[ :task ]}"
216
- end
217
-
218
- if result[ :updated_at ]
219
- puts_line " Updated: #{result[ :updated_at ]}"
220
- end
221
-
222
- puts_line " No active session state." unless result[ :worktree ] || result[ :pr ] || result[ :task ]
223
- end
224
- end
225
-
226
- include Session
227
- end
228
- end