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 +4 -4
- data/RELEASE.md +35 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +3 -40
- data/lib/carson/runtime/deliver.rb +1 -8
- data/lib/carson/runtime/local/worktree.rb +2 -66
- 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: d436c55fe8aa3dcd6d680a4051acf5456ccbf93ae4891bc7aeeb70878fa9a945
|
|
4
|
+
data.tar.gz: 501ccae43b713c7d0394b58af4816197c79e5bb643a617852b7982f089b5df5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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|
|
|
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|
|
|
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|
|
|
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:
|
|
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
|
-
#
|
|
3
|
-
#
|
|
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
|
|
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.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
|