carson 3.10.5 → 3.12.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: 29198792c7eb5bb9c02c43fc31d6a2ba04fc1430684e978cc365c2e74b47f810
4
- data.tar.gz: b0e164e172315cc707017a9c82e83328fdb1e84a5e4f983aa5aeabc9caae1b04
3
+ metadata.gz: d436c55fe8aa3dcd6d680a4051acf5456ccbf93ae4891bc7aeeb70878fa9a945
4
+ data.tar.gz: 501ccae43b713c7d0394b58af4816197c79e5bb643a617852b7982f089b5df5e
5
5
  SHA512:
6
- metadata.gz: 45361da34684eaf4d399f7d40eba060a63f3c364f41ed890ac448f6ffeeb4d886961f451ce393adec495240ffc5bff5bc95399b59d7afd08aaba0eadf0579fde
7
- data.tar.gz: b827c09fcf9cef558f237471abd328906ecda337f2bdef04a7991ee2547b2a31437840eb651d04e1ca659c1d55b891e84890cf57b857247240fd3a5ecd81f71c
6
+ metadata.gz: e0179948219588b434de25b80159c3b0c57fb57acc09ec57e02e2be480d65afc61e184048667cc0d8155f6ac870fcde80d15b31eff94ed79316285274a5c30a8
7
+ data.tar.gz: 22b1fd4044c4ec1918f1b4570e98728d1f645a343ecdb3ce87836c7c373fba6428b417b824f8248ca94620baf21cb48e4e5b1a98a84858b95946e729240f9ef3
data/RELEASE.md CHANGED
@@ -5,6 +5,29 @@ 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.12.0
9
+
10
+ ### What changed
11
+
12
+ - **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.
13
+ - **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.
14
+
15
+ ### Breaking changes
16
+
17
+ - `carson session` and `carson session clear` no longer exist.
18
+ - Session files in `~/.carson/sessions/` are no longer written or read. Existing files are inert.
19
+ - `carson status` worktree entries no longer include `owner`, `owner_pid`, `owner_task`, or `stale` fields in JSON output.
20
+
21
+ ## 3.11.0
22
+
23
+ ### What changed
24
+
25
+ - **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.
26
+
27
+ ### Breaking changes
28
+
29
+ - `carson worktree done <name>` no longer exists. Use `carson worktree remove <name>` instead. Add `--force` to override safety guards.
30
+
8
31
  ## 3.10.5
9
32
 
10
33
  ### What changed
@@ -16,6 +39,18 @@ Release-note scope rule:
16
39
 
17
40
  - No breaking changes. New safety guard — previously git-protected operations now fail earlier with clearer diagnostics.
18
41
 
42
+ ## 3.10.4
43
+
44
+ ### What changed
45
+
46
+ - **Worktree remove guards unpushed commits** — `carson worktree remove` now checks for unpushed commits before deleting a worktree. Blocks with recovery guidance (push command or `--force` to override). Prevents accidental destruction of work that exists only locally.
47
+ - **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by `worktree remove`, eliminating code duplication.
48
+ - **Fix resolve path from inside worktrees** — `resolve_worktree_path` now uses `main_worktree_root` instead of `repo_root` for bare-name resolution. Previously, calling `carson worktree remove <name>` from inside a worktree would look in the wrong directory.
49
+
50
+ ### Migration
51
+
52
+ - No breaking changes. `--force` overrides the new unpushed-commits guard.
53
+
19
54
  ## 3.10.3
20
55
 
21
56
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.5
1
+ 3.12.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]|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 }
@@ -180,7 +178,7 @@ module Carson
180
178
  json_flag = argv.delete( "--json" ) ? true : false
181
179
  action = argv.shift
182
180
  if action.to_s.strip.empty?
183
- err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
181
+ err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|remove <name>"
184
182
  err.puts parser
185
183
  return { command: :invalid }
186
184
  end
@@ -193,9 +191,6 @@ module Carson
193
191
  return { command: :invalid }
194
192
  end
195
193
  { command: "worktree:create", worktree_name: name, json: json_flag }
196
- when "done"
197
- name = argv.shift
198
- { command: "worktree:done", worktree_name: name, json: json_flag }
199
194
  when "remove"
200
195
  force = argv.delete( "--force" ) ? true : false
201
196
  worktree_path = argv.shift
@@ -205,7 +200,7 @@ module Carson
205
200
  end
206
201
  { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
207
202
  else
208
- err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
203
+ err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|remove <name>"
209
204
  { command: :invalid }
210
205
  end
211
206
  end
@@ -337,29 +332,6 @@ module Carson
337
332
  { command: :invalid }
338
333
  end
339
334
 
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
-
363
335
  def self.dispatch( parsed:, runtime: )
364
336
  command = parsed.fetch( :command )
365
337
  return Runtime::EXIT_ERROR if command == :invalid
@@ -379,8 +351,6 @@ module Carson
379
351
  runtime.prune_all!
380
352
  when "worktree:create"
381
353
  runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
382
- when "worktree:done"
383
- runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
384
354
  when "worktree:remove"
385
355
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
386
356
  when "onboard"
@@ -412,13 +382,6 @@ module Carson
412
382
  json_output: parsed.fetch( :json, false ),
413
383
  loop_seconds: parsed.fetch( :loop_seconds, nil )
414
384
  )
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 ) )
422
385
  else
423
386
  runtime.send( :puts_line, "Unknown command: #{command}" )
424
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,10 +74,7 @@ module Carson
78
74
 
79
75
  result[ :merged ] = true
80
76
 
81
- # Step 6: mark worktree done in 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
 
87
80
  deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
@@ -1,6 +1,6 @@
1
1
  # Safe worktree lifecycle management for coding agents.
2
- # Three operations: create, done (mark completed), remove (full cleanup).
3
- # Remove guards against unpushed commits and CWD-inside-worktree safe by default.
2
+ # Two operations: create and remove. Remove is safe by default — guards against
3
+ # CWD-inside-worktree and unpushed commits. Use --force to override.
4
4
  # Supports --json for machine-readable structured output with recovery commands.
5
5
  module Carson
6
6
  class Runtime
@@ -41,71 +41,12 @@ module Carson
41
41
  )
42
42
  end
43
43
 
44
- # Record active worktree in session state.
45
- update_session( worktree: { name: name, path: wt_path, branch: name } )
46
-
47
44
  worktree_finish(
48
45
  result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
49
46
  exit_code: EXIT_OK, json_output: json_output
50
47
  )
51
48
  end
52
49
 
53
- # Marks a worktree as completed without deleting it.
54
- # Verifies all changes are committed. Deferred deletion — cleanup happens later.
55
- def worktree_done!( name: nil, json_output: false )
56
- if name.to_s.strip.empty?
57
- return worktree_finish(
58
- result: { command: "worktree done", status: "error",
59
- error: "missing worktree name",
60
- recovery: "carson worktree done <name>" },
61
- exit_code: EXIT_ERROR, json_output: json_output
62
- )
63
- end
64
-
65
- resolved_path = resolve_worktree_path( worktree_path: name )
66
-
67
- unless worktree_registered?( path: resolved_path )
68
- return worktree_finish(
69
- result: { command: "worktree done", status: "error", name: name,
70
- error: "#{name} is not a registered worktree",
71
- recovery: "git worktree list" },
72
- exit_code: EXIT_ERROR, json_output: json_output
73
- )
74
- end
75
-
76
- # Check for uncommitted changes in the worktree.
77
- wt_status, _, status_result, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
78
- if status_result.success? && !wt_status.strip.empty?
79
- return worktree_finish(
80
- result: { command: "worktree done", status: "block", name: name,
81
- error: "worktree has uncommitted changes",
82
- recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" },
83
- exit_code: EXIT_BLOCK, json_output: json_output
84
- )
85
- end
86
-
87
- # Check for unpushed commits using shared guard.
88
- branch = worktree_branch( path: resolved_path )
89
- unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
90
- if unpushed
91
- return worktree_finish(
92
- result: { command: "worktree done", status: "block", name: name, branch: branch,
93
- error: unpushed[ :error ],
94
- recovery: unpushed[ :recovery ] },
95
- exit_code: EXIT_BLOCK, json_output: json_output
96
- )
97
- end
98
-
99
- # Clear worktree from session state.
100
- update_session( worktree: :clear )
101
-
102
- worktree_finish(
103
- result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
104
- next_step: "carson worktree remove #{name}" },
105
- exit_code: EXIT_OK, json_output: json_output
106
- )
107
- end
108
-
109
50
  # Removes a worktree: directory, git registration, and branch.
110
51
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
111
52
  # the user explicitly passes force: true via CLI --force flag.
@@ -210,7 +151,6 @@ module Carson
210
151
  remote_deleted = true
211
152
  end
212
153
  end
213
-
214
154
  worktree_finish(
215
155
  result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
216
156
  branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
@@ -245,10 +185,6 @@ module Carson
245
185
  puts_line "Worktree created: #{result[ :name ]}"
246
186
  puts_line " Path: #{result[ :path ]}"
247
187
  puts_line " Branch: #{result[ :branch ]}"
248
- when "worktree done"
249
- puts_line "Worktree done: #{result[ :name ]}"
250
- puts_line " Branch: #{result[ :branch ]}"
251
- puts_line " Cleanup later with `#{result[ :next_step ]}` or `carson housekeep`."
252
188
  when "worktree remove"
253
189
  unless verbose?
254
190
  puts_line "Worktree removed: #{result[ :name ]}"
@@ -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.10.5
4
+ version: 3.12.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