carson 3.7.0 → 3.9.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: 43f5ee17800cad35a51d2aaaf6ce306197bf2823cc6b0de4858912213d06e01b
4
- data.tar.gz: 51b3a3e91d84a98266b2bccee868e583c25609c5466e34c938b2fa2bcd6564fc
3
+ metadata.gz: ffbada076dd17e9f8ae9a85e9abce9abbc84e23b71324c8cde1ffec1db537071
4
+ data.tar.gz: 4654c5d248f856818ffa83600b4db21838c0f665bb6a16f2d45d566643312ec6
5
5
  SHA512:
6
- metadata.gz: c7bca3554a185fc1951292d62e0bfcfd6b63d77488d22013f14b93885e6b8d043d2d30e20e8e9725796d26bc3aaa031bcce8f0b28474ab3198bf77733c56f0c5
7
- data.tar.gz: ce3240db72d7fb1c6a5aa9f590628d23e3533778cc492def9c73951a8f5b6772a347cb8d64c79e0063fb79f67f16fb663c9a8ecdc33db7f9af15e0204678adfd
6
+ metadata.gz: b4e517ccc14a75a2389bf5743fdd095b2796605c5c7fbeb4d12af4b0f237f370a275e2ff244436da4439b10e44b38a7aceae44c26b858d2224a8b0a25b1d193f
7
+ data.tar.gz: a1de0764868ee6fda99d4f74f5ec10dfc2071a84e65102ab77749e83a8307aee37aa661d98b3670e1d62aeda28f1be1a05093283ac697de3a39ad542256e6da9
data/RELEASE.md CHANGED
@@ -5,6 +5,48 @@ 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.9.0 — Agent Coordination Signals
9
+
10
+ ### What changed
11
+
12
+ - **Per-session state files** — each Runtime instance gets a unique session ID (`<pid>-<timestamp>`) and its own session file at `~/.carson/sessions/<repo_slug>/<session_id>.json`. Multiple agents on the same repo each have independent state that does not collide.
13
+ - **Session ownership on worktrees** — `carson status` cross-references active session files to annotate each worktree with its owning session's PID, task, and staleness. Other agents can see which worktrees are in use and by whom.
14
+ - **Staleness detection** — sessions whose PID is no longer running and whose last update is older than 1 hour are marked as `stale`. This signals to other agents that the worktree may be abandoned and available for cleanup.
15
+ - **`session_list`** — new method that scans all session files for the current repo and returns structured data with staleness annotations.
16
+ - **Migration from 3.8 format** — old single-file session state (`<slug>.json`) is automatically migrated to the per-session directory format on first access.
17
+
18
+ ### UX
19
+
20
+ - `carson status` worktree listing now shows owner context: task description if set, PID if no task, or "stale session" if the owner is no longer running.
21
+ - `carson session --json` now includes `session_id` and `pid` fields for correlation by other agents.
22
+ - Human output unchanged when no session ownership data exists.
23
+
24
+ ### Migration
25
+
26
+ - Automatic. The old `<slug>.json` file is moved into the new `<slug>/` directory as `migrated.json` on first access. No user action required.
27
+
28
+ ## 3.8.0 — Session State
29
+
30
+ ### What changed
31
+
32
+ - **`carson session`** — reads and displays the current session state for a repository. Shows active worktree, PR, task description, and last-updated timestamp.
33
+ - **`carson session --task "description"`** — records a task description in session state so agents resuming work can discover the current objective.
34
+ - **`carson session clear`** — removes all session state for the current repository.
35
+ - **`carson session --json`** / **`carson session clear --json`** — machine-readable JSON output for all session commands.
36
+ - **Side-effect integration** — `worktree_create!` automatically records the active worktree in session state; `worktree_done!` clears it; `deliver!` records the PR number and URL.
37
+ - **Outsider-safe storage** — session files live at `~/.carson/sessions/<basename>-<hash>.json`, outside the user's repository.
38
+
39
+ ### UX
40
+
41
+ - Human output shows a concise summary: session name, worktree, PR, task, and timestamp.
42
+ - "No active session state" displayed when no context has been recorded.
43
+ - Session state persists across Carson invocations — agents can `carson session --json` to discover context without re-running discovery commands.
44
+ - `update_session` uses a `:clear` sentinel to selectively remove fields without touching others.
45
+
46
+ ### Migration
47
+
48
+ - No breaking changes. New command — no existing behaviour affected.
49
+
8
50
  ## 3.7.0 — Worktree JSON + Recovery
9
51
 
10
52
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.7.0
1
+ 3.9.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|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 [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|done|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]"
57
57
  end
58
58
  end
59
59
 
@@ -98,6 +98,8 @@ 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 )
101
103
  else
102
104
  parser.parse!( argv )
103
105
  { command: command }
@@ -335,6 +337,29 @@ module Carson
335
337
  { command: :invalid }
336
338
  end
337
339
 
340
+ def self.parse_session_command( argv:, err: )
341
+ json_flag = argv.delete( "--json" ) ? true : false
342
+ task_value = nil
343
+ # Check for --task "description"
344
+ task_index = argv.index( "--task" )
345
+ if task_index
346
+ argv.delete_at( task_index )
347
+ task_value = argv.delete_at( task_index )
348
+ if task_value.to_s.strip.empty?
349
+ err.puts "#{BADGE} Missing value for --task. Use: carson session --task \"description\""
350
+ return { command: :invalid }
351
+ end
352
+ end
353
+
354
+ action = argv.first
355
+ if action == "clear"
356
+ argv.shift
357
+ return { command: "session:clear", json: json_flag }
358
+ end
359
+
360
+ { command: "session", json: json_flag, task: task_value }
361
+ end
362
+
338
363
  def self.dispatch( parsed:, runtime: )
339
364
  command = parsed.fetch( :command )
340
365
  return Runtime::EXIT_ERROR if command == :invalid
@@ -387,6 +412,13 @@ module Carson
387
412
  json_output: parsed.fetch( :json, false ),
388
413
  loop_seconds: parsed.fetch( :loop_seconds, nil )
389
414
  )
415
+ when "session"
416
+ runtime.session!(
417
+ task: parsed.fetch( :task, nil ),
418
+ json_output: parsed.fetch( :json, false )
419
+ )
420
+ when "session:clear"
421
+ runtime.session_clear!( json_output: parsed.fetch( :json, false ) )
390
422
  else
391
423
  runtime.send( :puts_line, "Unknown command: #{command}" )
392
424
  Runtime::EXIT_ERROR
@@ -37,6 +37,9 @@ module Carson
37
37
  result[ :pr_number ] = pr_number
38
38
  result[ :pr_url ] = pr_url
39
39
 
40
+ # Record PR in session state.
41
+ update_session( pr: { number: pr_number, url: pr_url } )
42
+
40
43
  # Without --merge, we are done.
41
44
  unless merge
42
45
  return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
@@ -36,6 +36,9 @@ module Carson
36
36
  )
37
37
  end
38
38
 
39
+ # Record active worktree in session state.
40
+ update_session( worktree: { name: name, path: wt_path, branch: name } )
41
+
39
42
  worktree_finish(
40
43
  result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
41
44
  exit_code: EXIT_OK, json_output: json_output
@@ -92,6 +95,9 @@ module Carson
92
95
  end
93
96
  end
94
97
 
98
+ # Clear worktree from session state.
99
+ update_session( worktree: :clear )
100
+
95
101
  worktree_finish(
96
102
  result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
97
103
  next_step: "carson worktree remove #{name}" },
@@ -0,0 +1,228 @@
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
@@ -81,17 +81,47 @@ module Carson
81
81
  end
82
82
  end
83
83
 
84
- # Lists all worktrees with branch and lifecycle state.
84
+ # Lists all worktrees with branch, lifecycle state, and session ownership.
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
  # Filter out the main worktree (the repository root itself).
88
91
  entries.reject { |wt| wt.fetch( :path ) == repo_root }.map do |wt|
89
- {
92
+ name = File.basename( wt.fetch( :path ) )
93
+ info = {
90
94
  path: wt.fetch( :path ),
91
- name: File.basename( wt.fetch( :path ) ),
95
+ name: name,
92
96
  branch: wt.fetch( :branch, nil )
93
97
  }
98
+ owner = ownership[ name ]
99
+ if owner
100
+ info[ :owner ] = owner[ :session_id ]
101
+ info[ :owner_pid ] = owner[ :pid ]
102
+ info[ :owner_task ] = owner[ :task ]
103
+ info[ :stale ] = owner[ :stale ]
104
+ end
105
+ info
106
+ end
107
+ end
108
+
109
+ # Builds a name-to-session mapping for worktree ownership.
110
+ def build_worktree_ownership( sessions: )
111
+ result = {}
112
+ sessions.each do |session|
113
+ wt = session[ :worktree ]
114
+ next unless wt
115
+ name = wt[ :name ] || wt[ "name" ]
116
+ next unless name
117
+ result[ name ] = {
118
+ session_id: session[ :session_id ] || session[ "session_id" ],
119
+ pid: session[ :pid ] || session[ "pid" ],
120
+ task: session[ :task ] || session[ "task" ],
121
+ stale: session[ :stale ]
122
+ }
94
123
  end
124
+ result
95
125
  end
96
126
 
97
127
  # Queries open PRs via gh.
@@ -177,7 +207,8 @@ module Carson
177
207
  puts_line "Worktrees:"
178
208
  worktrees.each do |wt|
179
209
  branch_label = wt.fetch( :branch ) || "(detached)"
180
- puts_line " #{wt.fetch( :name )} #{branch_label}"
210
+ owner_label = format_worktree_owner( worktree: wt )
211
+ puts_line " #{wt.fetch( :name )} #{branch_label}#{owner_label}"
181
212
  end
182
213
  end
183
214
 
@@ -211,6 +242,24 @@ module Carson
211
242
  end
212
243
  end
213
244
 
245
+ # Formats owner annotation for a worktree entry.
246
+ def format_worktree_owner( worktree: )
247
+ owner = worktree[ :owner ]
248
+ return "" unless owner
249
+
250
+ stale = worktree[ :stale ]
251
+ task = worktree[ :owner_task ]
252
+ pid = worktree[ :owner_pid ]
253
+
254
+ if stale
255
+ " (stale session #{pid})"
256
+ elsif task
257
+ " (#{task})"
258
+ else
259
+ " (session #{pid})"
260
+ end
261
+ end
262
+
214
263
  # Formats sync status for display.
215
264
  def format_sync( sync: )
216
265
  case sync
@@ -222,3 +222,4 @@ 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.7.0
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -67,6 +67,7 @@ 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
70
71
  - lib/carson/runtime/setup.rb
71
72
  - lib/carson/runtime/status.rb
72
73
  - lib/carson/version.rb