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 +4 -4
- data/RELEASE.md +26 -1
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -33
- data/lib/carson/runtime/deliver.rb +23 -8
- data/lib/carson/runtime/local/worktree.rb +7 -7
- data/lib/carson/runtime/status.rb +6 -54
- data/lib/carson/runtime.rb +0 -1
- metadata +1 -2
- data/lib/carson/runtime/session.rb +0 -228
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d653b3c6ec67aa729186db46215c6e51c85a42ac82ca782f5004f9d84e32a31
|
|
4
|
+
data.tar.gz: 5caa70de1a5c9538c3ef3027ba0862445c9ff2d985ec6f4659231bb40d8e5cb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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.
|
|
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]|
|
|
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:
|
|
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
|
|
2
|
-
# Gathers branch,
|
|
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
|
|
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
|
-
|
|
95
|
-
info = {
|
|
92
|
+
{
|
|
96
93
|
path: wt.fetch( :path ),
|
|
97
|
-
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
|
-
|
|
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
|
data/lib/carson/runtime.rb
CHANGED
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.
|
|
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
|