carson 3.6.0 → 3.8.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: d430ed51ffe8ee87c8aa2a652c63d92ef7e9f0a65713b9104dd4df57dbcba90a
4
- data.tar.gz: d5bf899e7acc479cb5f673c0ba0323dce10e83a6c8d843f418d9db53adbbed38
3
+ metadata.gz: ec88c12e0edb3541ab43fbefd93c75ef87dfe6032217c1132f6e3cc9857428cf
4
+ data.tar.gz: cba06c9b0c85e0884157e6e2794a58140cbfd903da6eb61d9eb1e96f2f182860
5
5
  SHA512:
6
- metadata.gz: 903da2928ca57d07422e7450670efb7686603934fc2b7aa8122789fcea6ecce2e1b841420c15c2df39dc33b2789c4d3a85a0cb56dc5816aa1b0216fa103f7cb5
7
- data.tar.gz: 2c67d3aa87576d0124d3ea2fb31fdaf8457cbfb81199c539e103072636a1fcaef4e1274c3642d1a843117c1cd88a69e437cf369c5f981a06fed50d6e57e0d2f3
6
+ metadata.gz: 33367440b238d8ba9872aac28da62a10bd74aa6179398bde682860ea798851af3943d9f932a5a157b0913449fa82c9961800f0d832a065fe73b6f9b902a148d6
7
+ data.tar.gz: 4fc299aa1cc6b15f0a7498659912395691213fdfd2d778594a4c6ba52ca9ad77f87db810dc91bdb4a47d1f4aa34a2f7c375d62a5b0d9a481847189798cc36e5d
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.8.0 — Session State
9
+
10
+ ### What changed
11
+
12
+ - **`carson session`** — reads and displays the current session state for a repository. Shows active worktree, PR, task description, and last-updated timestamp.
13
+ - **`carson session --task "description"`** — records a task description in session state so agents resuming work can discover the current objective.
14
+ - **`carson session clear`** — removes all session state for the current repository.
15
+ - **`carson session --json`** / **`carson session clear --json`** — machine-readable JSON output for all session commands.
16
+ - **Side-effect integration** — `worktree_create!` automatically records the active worktree in session state; `worktree_done!` clears it; `deliver!` records the PR number and URL.
17
+ - **Outsider-safe storage** — session files live at `~/.carson/sessions/<basename>-<hash>.json`, outside the user's repository.
18
+
19
+ ### UX
20
+
21
+ - Human output shows a concise summary: session name, worktree, PR, task, and timestamp.
22
+ - "No active session state" displayed when no context has been recorded.
23
+ - Session state persists across Carson invocations — agents can `carson session --json` to discover context without re-running discovery commands.
24
+ - `update_session` uses a `:clear` sentinel to selectively remove fields without touching others.
25
+
26
+ ### Migration
27
+
28
+ - No breaking changes. New command — no existing behaviour affected.
29
+
30
+ ## 3.7.0 — Worktree JSON + Recovery
31
+
32
+ ### What changed
33
+
34
+ - **`carson worktree --json create|done|remove`** — machine-readable JSON output for all three worktree commands. Each returns a structured envelope with `command`, `status`, `name`, `exit_code`, and context-specific fields (`path`, `branch`, `next_step`).
35
+ - **Recovery-aware worktree errors** — every error and block path includes a `recovery` field with the exact command to run next. Dirty worktree, missing name, unregistered worktree, unpushed commits — all include actionable recovery.
36
+ - **Unified `worktree_finish` / `print_worktree_human`** — consistent output pattern matching deliver, sync, and prune.
37
+
38
+ ### UX
39
+
40
+ - Human output preserved unchanged when `--json` is not passed.
41
+ - `worktree done` includes `next_step` in JSON output pointing to `carson worktree remove`.
42
+ - `worktree remove` includes `branch_deleted` and `remote_deleted` booleans for complete lifecycle visibility.
43
+
44
+ ### Migration
45
+
46
+ - No breaking changes. All worktree commands without `--json` behave identically to 3.6.0.
47
+
8
48
  ## 3.6.0 — Prune JSON + Recovery
9
49
 
10
50
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.6.0
1
+ 3.8.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 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 }
@@ -175,6 +177,7 @@ module Carson
175
177
  end
176
178
 
177
179
  def self.parse_worktree_subcommand( argv:, parser:, err: )
180
+ json_flag = argv.delete( "--json" ) ? true : false
178
181
  action = argv.shift
179
182
  if action.to_s.strip.empty?
180
183
  err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
@@ -189,10 +192,10 @@ module Carson
189
192
  err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
190
193
  return { command: :invalid }
191
194
  end
192
- { command: "worktree:create", worktree_name: name }
195
+ { command: "worktree:create", worktree_name: name, json: json_flag }
193
196
  when "done"
194
197
  name = argv.shift
195
- { command: "worktree:done", worktree_name: name }
198
+ { command: "worktree:done", worktree_name: name, json: json_flag }
196
199
  when "remove"
197
200
  force = argv.delete( "--force" ) ? true : false
198
201
  worktree_path = argv.shift
@@ -200,7 +203,7 @@ module Carson
200
203
  err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
201
204
  return { command: :invalid }
202
205
  end
203
- { command: "worktree:remove", worktree_path: worktree_path, force: force }
206
+ { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
204
207
  else
205
208
  err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
206
209
  { command: :invalid }
@@ -334,6 +337,29 @@ module Carson
334
337
  { command: :invalid }
335
338
  end
336
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
+
337
363
  def self.dispatch( parsed:, runtime: )
338
364
  command = parsed.fetch( :command )
339
365
  return Runtime::EXIT_ERROR if command == :invalid
@@ -352,11 +378,11 @@ module Carson
352
378
  when "prune:all"
353
379
  runtime.prune_all!
354
380
  when "worktree:create"
355
- runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
381
+ runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
356
382
  when "worktree:done"
357
- runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ) )
383
+ runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
358
384
  when "worktree:remove"
359
- runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
385
+ runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
360
386
  when "onboard"
361
387
  runtime.onboard!
362
388
  when "refresh"
@@ -386,6 +412,13 @@ module Carson
386
412
  json_output: parsed.fetch( :json, false ),
387
413
  loop_seconds: parsed.fetch( :loop_seconds, nil )
388
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 ) )
389
422
  else
390
423
  runtime.send( :puts_line, "Unknown command: #{command}" )
391
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 )
@@ -1,19 +1,23 @@
1
+ # Safe worktree lifecycle management for coding agents.
2
+ # Three operations: create, done (mark completed), remove (batch cleanup).
3
+ # The deferred deletion model: worktrees persist after use, cleaned up later.
4
+ # Supports --json for machine-readable structured output with recovery commands.
1
5
  module Carson
2
6
  class Runtime
3
7
  module Local
4
- # Safe worktree lifecycle management for coding agents.
5
- # Three operations: create, done (mark completed), remove (batch cleanup).
6
- # The deferred deletion model: worktrees persist after use, cleaned up later.
7
8
 
8
9
  # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
9
- def worktree_create!( name: )
10
+ def worktree_create!( name:, json_output: false )
10
11
  worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
11
12
  wt_path = File.join( worktrees_dir, name )
12
13
 
13
14
  if Dir.exist?( wt_path )
14
- puts_line "ERROR: worktree already exists: #{name}"
15
- puts_line " Path: #{wt_path}"
16
- return EXIT_ERROR
15
+ return worktree_finish(
16
+ result: { command: "worktree create", status: "error", name: name, path: wt_path,
17
+ error: "worktree already exists: #{name}",
18
+ recovery: "carson worktree remove #{name}, then retry" },
19
+ exit_code: EXIT_ERROR, json_output: json_output
20
+ )
17
21
  end
18
22
 
19
23
  # Determine the base branch (main branch from config).
@@ -25,38 +29,54 @@ module Carson
25
29
  unless wt_success
26
30
  error_text = wt_stderr.to_s.strip
27
31
  error_text = "unable to create worktree" if error_text.empty?
28
- puts_line "ERROR: #{error_text}"
29
- return EXIT_ERROR
32
+ return worktree_finish(
33
+ result: { command: "worktree create", status: "error", name: name,
34
+ error: error_text },
35
+ exit_code: EXIT_ERROR, json_output: json_output
36
+ )
30
37
  end
31
38
 
32
- puts_line "Worktree created: #{name}"
33
- puts_line " Path: #{wt_path}"
34
- puts_line " Branch: #{name}"
35
- EXIT_OK
39
+ # Record active worktree in session state.
40
+ update_session( worktree: { name: name, path: wt_path, branch: name } )
41
+
42
+ worktree_finish(
43
+ result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
44
+ exit_code: EXIT_OK, json_output: json_output
45
+ )
36
46
  end
37
47
 
38
48
  # Marks a worktree as completed without deleting it.
39
49
  # Verifies all changes are committed. Deferred deletion — cleanup happens later.
40
- def worktree_done!( name: nil )
50
+ def worktree_done!( name: nil, json_output: false )
41
51
  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
52
+ return worktree_finish(
53
+ result: { command: "worktree done", status: "error",
54
+ error: "missing worktree name",
55
+ recovery: "carson worktree done <name>" },
56
+ exit_code: EXIT_ERROR, json_output: json_output
57
+ )
45
58
  end
46
59
 
47
60
  resolved_path = resolve_worktree_path( worktree_path: name )
48
61
 
49
62
  unless worktree_registered?( path: resolved_path )
50
- puts_line "ERROR: #{name} is not a registered worktree."
51
- return EXIT_ERROR
63
+ return worktree_finish(
64
+ result: { command: "worktree done", status: "error", name: name,
65
+ error: "#{name} is not a registered worktree",
66
+ recovery: "git worktree list" },
67
+ exit_code: EXIT_ERROR, json_output: json_output
68
+ )
52
69
  end
53
70
 
54
71
  # Check for uncommitted changes in the worktree.
55
72
  wt_status, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
56
73
  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
74
+ return worktree_finish(
75
+ result: { command: "worktree done", status: "block", name: name,
76
+ error: "worktree has uncommitted changes",
77
+ recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" },
78
+ exit_code: EXIT_BLOCK, json_output: json_output
79
+ )
60
80
  end
61
81
 
62
82
  # Check for unpushed commits.
@@ -66,39 +86,57 @@ module Carson
66
86
  remote_ref = "#{remote}/#{branch}"
67
87
  ahead, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
68
88
  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
89
+ return worktree_finish(
90
+ result: { command: "worktree done", status: "block", name: name, branch: branch,
91
+ error: "worktree has unpushed commits",
92
+ recovery: "git -C #{resolved_path} push #{remote} #{branch}" },
93
+ exit_code: EXIT_BLOCK, json_output: json_output
94
+ )
72
95
  end
73
96
  end
74
97
 
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
98
+ # Clear worktree from session state.
99
+ update_session( worktree: :clear )
100
+
101
+ worktree_finish(
102
+ result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
103
+ next_step: "carson worktree remove #{name}" },
104
+ exit_code: EXIT_OK, json_output: json_output
105
+ )
79
106
  end
80
107
 
81
108
  # Removes a worktree: directory, git registration, and branch.
82
109
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
83
110
  # the user explicitly passes force: true via CLI --force flag.
84
- def worktree_remove!( worktree_path:, force: false )
111
+ def worktree_remove!( worktree_path:, force: false, json_output: false )
85
112
  fingerprint_status = block_if_outsider_fingerprints!
86
- return fingerprint_status unless fingerprint_status.nil?
113
+ unless fingerprint_status.nil?
114
+ if json_output
115
+ out.puts JSON.pretty_generate( {
116
+ command: "worktree remove", status: "block",
117
+ error: "Carson-owned artefacts detected in host repository",
118
+ recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
119
+ exit_code: EXIT_BLOCK
120
+ } )
121
+ end
122
+ return fingerprint_status
123
+ end
87
124
 
88
125
  resolved_path = resolve_worktree_path( worktree_path: worktree_path )
89
126
 
90
127
  unless worktree_registered?( path: resolved_path )
91
- puts_line "ERROR: #{resolved_path} is not a registered worktree."
92
- puts_line " Registered worktrees:"
93
- worktree_list.each { |wt| puts_line " - #{wt.fetch( :path )} [#{wt.fetch( :branch )}]" }
94
- return EXIT_ERROR
128
+ return worktree_finish(
129
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
130
+ error: "#{resolved_path} is not a registered worktree",
131
+ recovery: "git worktree list" },
132
+ exit_code: EXIT_ERROR, json_output: json_output
133
+ )
95
134
  end
96
135
 
97
136
  branch = worktree_branch( path: resolved_path )
98
137
  puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
99
138
 
100
139
  # Step 1: remove the worktree (directory + git registration).
101
- # Try safe removal first. Only use --force if the user explicitly requested it.
102
140
  rm_args = [ "worktree", "remove" ]
103
141
  rm_args << "--force" if force
104
142
  rm_args << resolved_path
@@ -107,40 +145,96 @@ module Carson
107
145
  error_text = rm_stderr.to_s.strip
108
146
  error_text = "unable to remove worktree" if error_text.empty?
109
147
  if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
110
- puts_line "Worktree has uncommitted changes: #{File.basename( resolved_path )}"
111
- puts_line " Commit or discard changes first, or use --force to override."
112
- else
113
- puts_line "ERROR: #{error_text}"
148
+ return worktree_finish(
149
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
150
+ error: "worktree has uncommitted changes",
151
+ recovery: "commit or discard changes first, or use --force to override" },
152
+ exit_code: EXIT_ERROR, json_output: json_output
153
+ )
114
154
  end
115
- return EXIT_ERROR
155
+ return worktree_finish(
156
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
157
+ error: error_text },
158
+ exit_code: EXIT_ERROR, json_output: json_output
159
+ )
116
160
  end
117
161
  puts_verbose "worktree_removed: #{resolved_path}"
118
162
 
119
163
  # Step 2: delete the local branch.
164
+ branch_deleted = false
120
165
  if branch && !config.protected_branches.include?( branch )
121
166
  _, del_stderr, del_success, = git_run( "branch", "-D", branch )
122
167
  if del_success
123
168
  puts_verbose "branch_deleted: #{branch}"
169
+ branch_deleted = true
124
170
  else
125
171
  puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
126
172
  end
127
173
  end
128
174
 
129
175
  # Step 3: delete the remote branch (best-effort).
176
+ remote_deleted = false
130
177
  if branch && !config.protected_branches.include?( branch )
131
178
  remote_branch = branch
132
- git_run( "push", config.git_remote, "--delete", remote_branch )
133
- puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
179
+ _, _, rd_success, = git_run( "push", config.git_remote, "--delete", remote_branch )
180
+ if rd_success
181
+ puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
182
+ remote_deleted = true
183
+ end
134
184
  end
135
185
 
136
- unless verbose?
137
- puts_line "Worktree removed: #{File.basename( resolved_path )}"
138
- end
139
- EXIT_OK
186
+ worktree_finish(
187
+ result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
188
+ branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
189
+ exit_code: EXIT_OK, json_output: json_output
190
+ )
140
191
  end
141
192
 
142
193
  private
143
194
 
195
+ # Unified output for worktree results — JSON or human-readable.
196
+ def worktree_finish( result:, exit_code:, json_output: )
197
+ result[ :exit_code ] = exit_code
198
+
199
+ if json_output
200
+ out.puts JSON.pretty_generate( result )
201
+ else
202
+ print_worktree_human( result: result )
203
+ end
204
+
205
+ exit_code
206
+ end
207
+
208
+ # Human-readable output for worktree results.
209
+ def print_worktree_human( result: )
210
+ command = result[ :command ]
211
+ status = result[ :status ]
212
+
213
+ case status
214
+ when "ok"
215
+ case command
216
+ when "worktree create"
217
+ puts_line "Worktree created: #{result[ :name ]}"
218
+ puts_line " Path: #{result[ :path ]}"
219
+ puts_line " Branch: #{result[ :branch ]}"
220
+ when "worktree done"
221
+ puts_line "Worktree done: #{result[ :name ]}"
222
+ puts_line " Branch: #{result[ :branch ]}"
223
+ puts_line " Cleanup later with `#{result[ :next_step ]}` or `carson housekeep`."
224
+ when "worktree remove"
225
+ unless verbose?
226
+ puts_line "Worktree removed: #{result[ :name ]}"
227
+ end
228
+ end
229
+ when "error"
230
+ puts_line "ERROR: #{result[ :error ]}"
231
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
232
+ when "block"
233
+ puts_line "#{result[ :error ]&.capitalize || 'Blocked'}: #{result[ :name ]}"
234
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
235
+ end
236
+ end
237
+
144
238
  # Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
145
239
  def resolve_worktree_path( worktree_path: )
146
240
  return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
@@ -0,0 +1,161 @@
1
+ # Session state persistence for coding agents.
2
+ # Maintains a lightweight JSON file per repository in ~/.carson/sessions/ so
3
+ # agents can discover the current working context without re-running discovery commands.
4
+ # Respects the outsider boundary: state lives in Carson's own space, not the repository.
5
+ require "digest"
6
+
7
+ module Carson
8
+ class Runtime
9
+ module Session
10
+ # Reads and displays current session state for this repository.
11
+ def session!( task: nil, json_output: false )
12
+ if task
13
+ update_session( worktree: nil, pr: nil, task: task )
14
+ state = read_session
15
+ return session_finish(
16
+ result: state.merge( command: "session", status: "ok" ),
17
+ exit_code: EXIT_OK, json_output: json_output
18
+ )
19
+ end
20
+
21
+ state = read_session
22
+ session_finish(
23
+ result: state.merge( command: "session", status: "ok" ),
24
+ exit_code: EXIT_OK, json_output: json_output
25
+ )
26
+ end
27
+
28
+ # Clears session state for this repository.
29
+ def session_clear!( json_output: false )
30
+ path = session_file_path
31
+ File.delete( path ) if File.exist?( path )
32
+ session_finish(
33
+ result: { command: "session clear", status: "ok", repo: repo_root },
34
+ exit_code: EXIT_OK, json_output: json_output
35
+ )
36
+ end
37
+
38
+ # Records session state — called as a side effect from other commands.
39
+ # Only non-nil values are updated; nil values preserve existing state.
40
+ def update_session( worktree: nil, pr: nil, task: nil )
41
+ state = read_session
42
+
43
+ if worktree == :clear
44
+ state.delete( :worktree )
45
+ elsif worktree
46
+ state[ :worktree ] = worktree
47
+ end
48
+
49
+ if pr == :clear
50
+ state.delete( :pr )
51
+ elsif pr
52
+ state[ :pr ] = pr
53
+ end
54
+
55
+ state[ :task ] = task if task
56
+ state[ :repo ] = repo_root
57
+ state[ :updated_at ] = Time.now.utc.iso8601
58
+
59
+ write_session( state )
60
+ end
61
+
62
+ private
63
+
64
+ # Returns the session file path for this repository.
65
+ def session_file_path
66
+ sessions_dir = File.join( carson_home, "sessions" )
67
+ FileUtils.mkdir_p( sessions_dir )
68
+ slug = session_repo_slug
69
+ File.join( sessions_dir, "#{slug}.json" )
70
+ end
71
+
72
+ # Generates a readable, unique slug for the repository: basename-shortsha.
73
+ def session_repo_slug
74
+ basename = File.basename( repo_root )
75
+ short_hash = Digest::SHA256.hexdigest( repo_root )[ 0, 8 ]
76
+ "#{basename}-#{short_hash}"
77
+ end
78
+
79
+ # Returns Carson's home directory (~/.carson).
80
+ def carson_home
81
+ home = ENV.fetch( "HOME", "" ).to_s
82
+ return File.join( home, ".carson" ) if !home.empty? && home.start_with?( "/" )
83
+
84
+ File.join( "/tmp", ".carson" )
85
+ end
86
+
87
+ # Reads session state from disk. Returns an empty hash if no state exists.
88
+ def read_session
89
+ path = session_file_path
90
+ return { repo: repo_root } unless File.exist?( path )
91
+
92
+ data = JSON.parse( File.read( path ), symbolize_names: true )
93
+ data[ :repo ] = repo_root
94
+ data
95
+ rescue JSON::ParserError, StandardError
96
+ { repo: repo_root }
97
+ end
98
+
99
+ # Writes session state to disk as formatted JSON.
100
+ def write_session( state )
101
+ path = session_file_path
102
+ # Convert symbol keys to strings for clean JSON output.
103
+ string_state = deep_stringify_keys( state )
104
+ File.write( path, JSON.pretty_generate( string_state ) + "\n" )
105
+ end
106
+
107
+ # Recursively converts symbol keys to strings for JSON serialisation.
108
+ def deep_stringify_keys( hash )
109
+ hash.each_with_object( {} ) do |( key, value ), result|
110
+ string_key = key.to_s
111
+ result[ string_key ] = value.is_a?( Hash ) ? deep_stringify_keys( value ) : value
112
+ end
113
+ end
114
+
115
+ # Unified output for session results — JSON or human-readable.
116
+ def session_finish( result:, exit_code:, json_output: )
117
+ result[ :exit_code ] = exit_code
118
+
119
+ if json_output
120
+ out.puts JSON.pretty_generate( result )
121
+ else
122
+ print_session_human( result: result )
123
+ end
124
+
125
+ exit_code
126
+ end
127
+
128
+ # Human-readable output for session state.
129
+ def print_session_human( result: )
130
+ if result[ :command ] == "session clear"
131
+ puts_line "Session state cleared."
132
+ return
133
+ end
134
+
135
+ puts_line "Session: #{File.basename( result[ :repo ].to_s )}"
136
+
137
+ if result[ :worktree ]
138
+ wt = result[ :worktree ]
139
+ puts_line " Worktree: #{wt[ :name ] || wt[ "name" ]} (#{wt[ :branch ] || wt[ "branch" ]})"
140
+ end
141
+
142
+ if result[ :pr ]
143
+ pr = result[ :pr ]
144
+ puts_line " PR: ##{pr[ :number ] || pr[ "number" ]} #{pr[ :url ] || pr[ "url" ]}"
145
+ end
146
+
147
+ if result[ :task ]
148
+ puts_line " Task: #{result[ :task ]}"
149
+ end
150
+
151
+ if result[ :updated_at ]
152
+ puts_line " Updated: #{result[ :updated_at ]}"
153
+ end
154
+
155
+ puts_line " No active session state." unless result[ :worktree ] || result[ :pr ] || result[ :task ]
156
+ end
157
+ end
158
+
159
+ include Session
160
+ end
161
+ end
@@ -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.6.0
4
+ version: 3.8.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