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 +4 -4
- data/RELEASE.md +40 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +40 -7
- data/lib/carson/runtime/deliver.rb +3 -0
- data/lib/carson/runtime/local/worktree.rb +141 -47
- data/lib/carson/runtime/session.rb +161 -0
- data/lib/carson/runtime.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec88c12e0edb3541ab43fbefd93c75ef87dfe6032217c1132f6e3cc9857428cf
|
|
4
|
+
data.tar.gz: cba06c9b0c85e0884157e6e2794a58140cbfd903da6eb61d9eb1e96f2f182860
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
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.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
|