ace-hitl 0.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/hitl/config.yml +3 -0
  3. data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +13 -0
  4. data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +13 -0
  5. data/CHANGELOG.md +128 -0
  6. data/README.md +42 -0
  7. data/Rakefile +10 -0
  8. data/docs/demo/ace-hitl-scope-and-answer.tape.yml +37 -0
  9. data/docs/demo/fixtures/demo-context.md +7 -0
  10. data/docs/usage.md +112 -0
  11. data/exe/ace-hitl +18 -0
  12. data/handbook/README.md +14 -0
  13. data/handbook/skills/as-hitl/SKILL.md +29 -0
  14. data/handbook/workflow-instructions/hitl.wf.md +110 -0
  15. data/lib/ace/hitl/atoms/hitl_file_pattern.rb +20 -0
  16. data/lib/ace/hitl/atoms/hitl_id_formatter.rb +15 -0
  17. data/lib/ace/hitl/cli/commands/create.rb +76 -0
  18. data/lib/ace/hitl/cli/commands/list.rb +63 -0
  19. data/lib/ace/hitl/cli/commands/show.rb +79 -0
  20. data/lib/ace/hitl/cli/commands/update.rb +127 -0
  21. data/lib/ace/hitl/cli/commands/wait.rb +74 -0
  22. data/lib/ace/hitl/cli.rb +62 -0
  23. data/lib/ace/hitl/models/hitl_event.rb +32 -0
  24. data/lib/ace/hitl/molecules/hitl_answer_editor.rb +25 -0
  25. data/lib/ace/hitl/molecules/hitl_config_loader.rb +50 -0
  26. data/lib/ace/hitl/molecules/hitl_creator.rb +207 -0
  27. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +53 -0
  28. data/lib/ace/hitl/molecules/hitl_loader.rb +113 -0
  29. data/lib/ace/hitl/molecules/hitl_resolver.rb +30 -0
  30. data/lib/ace/hitl/molecules/hitl_scanner.rb +42 -0
  31. data/lib/ace/hitl/molecules/resume_dispatcher.rb +99 -0
  32. data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +77 -0
  33. data/lib/ace/hitl/organisms/hitl_manager.rb +462 -0
  34. data/lib/ace/hitl/version.rb +7 -0
  35. data/lib/ace/hitl.rb +24 -0
  36. metadata +164 -0
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require "yaml"
6
+ require "ace/support/items"
7
+ require_relative "../atoms/hitl_file_pattern"
8
+ require_relative "../atoms/hitl_id_formatter"
9
+ require_relative "hitl_loader"
10
+
11
+ module Ace
12
+ module Hitl
13
+ module Molecules
14
+ class HitlCreator
15
+ def initialize(root_dir:, config: {})
16
+ @root_dir = root_dir
17
+ @config = config
18
+ end
19
+
20
+ def create(title,
21
+ kind: nil,
22
+ questions: [],
23
+ tags: [],
24
+ assignment: nil,
25
+ step: nil,
26
+ step_name: nil,
27
+ resume_instructions: nil,
28
+ move_to: nil,
29
+ time: Time.now.utc)
30
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
31
+
32
+ effective_kind = kind || @config.dig("hitl", "default_kind") || "clarification"
33
+ id = generate_unique_id(time)
34
+ folder_slug = generate_folder_slug(title)
35
+ file_slug = generate_file_slug(title)
36
+
37
+ target_dir = determine_target_dir(move_to)
38
+ FileUtils.mkdir_p(target_dir)
39
+
40
+ folder_name, _ = unique_folder_name(id, folder_slug, target_dir)
41
+ item_dir = File.join(target_dir, folder_name)
42
+ FileUtils.mkdir_p(item_dir)
43
+
44
+ frontmatter = {
45
+ "id" => id,
46
+ "title" => title,
47
+ "kind" => effective_kind,
48
+ "status" => "pending",
49
+ "tags" => tags,
50
+ "questions" => questions,
51
+ "assignment" => assignment,
52
+ "step" => step,
53
+ "step_name" => step_name,
54
+ "resume_instructions" => resume_instructions,
55
+ "answered" => false,
56
+ "created_at" => time
57
+ }.merge(infer_requester_context(assignment: assignment, step: step)).compact
58
+
59
+ body = build_body(title: title, questions: questions)
60
+ content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
61
+
62
+ item_file = File.join(item_dir, Atoms::HitlFilePattern.filename(id, file_slug))
63
+ File.write(item_file, content)
64
+
65
+ loader = HitlLoader.new
66
+ special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
67
+ item_dir,
68
+ root: @root_dir
69
+ )
70
+ loader.load(item_dir, id: id, special_folder: special_folder)
71
+ end
72
+
73
+ private
74
+
75
+ def generate_unique_id(base_time)
76
+ time = base_time
77
+ 1000.times do
78
+ id = Atoms::HitlIdFormatter.generate(time)
79
+ return id unless hitl_id_exists?(id)
80
+
81
+ time += 2
82
+ end
83
+
84
+ raise "Failed to generate unique HITL ID after 1000 attempts"
85
+ end
86
+
87
+ def hitl_id_exists?(id)
88
+ pattern = File.join(@root_dir, "**", "#{id}-*")
89
+ !Dir.glob(pattern).empty?
90
+ end
91
+
92
+ def unique_folder_name(id, slug, target_dir)
93
+ folder_name = Atoms::HitlFilePattern.folder_name(id, slug)
94
+ candidate_dir = File.join(target_dir, folder_name)
95
+ return [folder_name, slug] unless Dir.exist?(candidate_dir)
96
+
97
+ counter = 2
98
+ loop do
99
+ unique_slug = "#{slug}-#{counter}"
100
+ folder_name = Atoms::HitlFilePattern.folder_name(id, unique_slug)
101
+ candidate_dir = File.join(target_dir, folder_name)
102
+ break [folder_name, unique_slug] unless Dir.exist?(candidate_dir)
103
+
104
+ counter += 1
105
+ end
106
+ end
107
+
108
+ def generate_folder_slug(title)
109
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
110
+ words = sanitized.split("-")
111
+ words.take(6).join("-").then { |s| s.empty? ? "hitl" : s }
112
+ end
113
+
114
+ def generate_file_slug(title)
115
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
116
+ words = sanitized.split("-")
117
+ words.take(8).join("-").then { |s| s.empty? ? "hitl" : s }
118
+ end
119
+
120
+ def determine_target_dir(move_to)
121
+ return @root_dir unless move_to
122
+
123
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(move_to)
124
+ candidate = File.expand_path(File.join(@root_dir, normalized))
125
+ root_real = File.expand_path(@root_dir)
126
+
127
+ unless candidate.start_with?(root_real + File::SEPARATOR) || candidate == root_real
128
+ raise ArgumentError, "Path traversal detected in --move-to option"
129
+ end
130
+
131
+ candidate
132
+ end
133
+
134
+ def build_body(title:, questions: [])
135
+ question_lines = if questions.empty?
136
+ "- (pending human input)"
137
+ else
138
+ questions.map { |q| "- #{q}" }.join("\n")
139
+ end
140
+
141
+ <<~BODY
142
+ # #{title}
143
+
144
+ ## Questions
145
+
146
+ #{question_lines}
147
+
148
+ ## Answer
149
+
150
+ BODY
151
+ end
152
+
153
+ def infer_requester_context(assignment:, step:)
154
+ assignment_id = assignment.to_s.split("@", 2).first.to_s.strip
155
+ return {} if assignment_id.empty?
156
+
157
+ sessions_dir = File.join(project_root, ".ace-local", "assign", assignment_id, "sessions")
158
+ return {} unless Dir.exist?(sessions_dir)
159
+
160
+ candidates = []
161
+ ancestors_for_step(step).each do |number|
162
+ path = File.join(sessions_dir, "#{number}-session.yml")
163
+ candidates << path if File.exist?(path)
164
+ end
165
+ newest = Dir.glob(File.join(sessions_dir, "*-session.yml")).sort_by { |path| File.mtime(path) }.reverse
166
+ candidates.concat(newest)
167
+
168
+ candidates.uniq.each do |path|
169
+ data = YAML.safe_load_file(path, permitted_classes: [Time], aliases: false) || {}
170
+ next unless data.is_a?(Hash)
171
+
172
+ provider = data["provider"].to_s.strip
173
+ model = data["model"].to_s.strip
174
+ session_id = data["session_id"].to_s.strip
175
+ next if provider.empty? && model.empty? && session_id.empty?
176
+
177
+ return {
178
+ "requester_provider" => (provider.empty? ? nil : provider),
179
+ "requester_model" => (model.empty? ? nil : model),
180
+ "requester_session_id" => (session_id.empty? ? nil : session_id)
181
+ }.compact
182
+ end
183
+
184
+ {}
185
+ rescue StandardError
186
+ {}
187
+ end
188
+
189
+ def ancestors_for_step(step)
190
+ raw = step.to_s.strip
191
+ return [] if raw.empty?
192
+
193
+ parts = raw.split(".")
194
+ (1..parts.length).map { |count| parts[0, count].join(".") }.reverse
195
+ end
196
+
197
+ def project_root
198
+ expanded = File.expand_path(@root_dir)
199
+ suffix = "#{File::SEPARATOR}.ace-local#{File::SEPARATOR}hitl"
200
+ return expanded[0...-suffix.length] if expanded.end_with?(suffix)
201
+
202
+ Dir.pwd
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Molecules
6
+ # Formats HITL events for list output with stats footer.
7
+ class HitlDisplayFormatter
8
+ C = Ace::Support::Items::Atoms::AnsiColors
9
+
10
+ STATUS_SYMBOLS = {
11
+ "pending" => "○",
12
+ "answered" => "✓"
13
+ }.freeze
14
+
15
+ STATUS_ORDER = %w[pending answered].freeze
16
+
17
+ def self.format_list(events, total_count: nil, global_folder_stats: nil)
18
+ body = if events.empty?
19
+ "No HITL events found"
20
+ else
21
+ events.sort_by(&:id).map { |event| format_list_line(event) }.join("\n")
22
+ end
23
+
24
+ stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(events, :status)
25
+ folder_stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(events, :special_folder)
26
+ footer = Ace::Support::Items::Atoms::StatsLineFormatter.format(
27
+ label: "HITL Events",
28
+ stats: stats,
29
+ status_order: STATUS_ORDER,
30
+ status_icons: STATUS_SYMBOLS,
31
+ folder_stats: folder_stats,
32
+ total_count: total_count,
33
+ global_folder_stats: global_folder_stats
34
+ )
35
+
36
+ "#{body}\n\n#{footer}"
37
+ end
38
+
39
+ def self.format_list_line(event)
40
+ status_sym = STATUS_SYMBOLS[event.status] || "○"
41
+ id_str = C.colorize(event.id, C::DIM)
42
+ tags_str = event.tags.any? ? C.colorize(" [#{event.tags.join(", ")}]", C::DIM) : ""
43
+ folder_label = event.special_folder ? Ace::Support::Items::Atoms::SpecialFolderDetector.short_name(event.special_folder) : nil
44
+ folder_str = folder_label ? C.colorize(" (#{folder_label})", C::DIM) : ""
45
+
46
+ "#{status_sym} #{id_str} #{event.title}#{tags_str}#{folder_str}"
47
+ end
48
+
49
+ private_class_method :format_list_line
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "ace/support/items"
5
+ require_relative "../atoms/hitl_file_pattern"
6
+ require_relative "../models/hitl_event"
7
+
8
+ module Ace
9
+ module Hitl
10
+ module Molecules
11
+ class HitlLoader
12
+ def load(dir_path, id: nil, special_folder: nil)
13
+ return nil unless Dir.exist?(dir_path)
14
+
15
+ hitl_file = Dir.glob(File.join(dir_path, Atoms::HitlFilePattern::FILE_GLOB)).first
16
+ return nil unless hitl_file
17
+
18
+ folder_name = File.basename(dir_path)
19
+ id ||= extract_id(folder_name)
20
+
21
+ content = File.read(hitl_file)
22
+ frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
23
+ answer = extract_answer(body)
24
+
25
+ title = frontmatter["title"] ||
26
+ Ace::Support::Items::Atoms::TitleExtractor.extract(body) ||
27
+ folder_name
28
+
29
+ created_at = parse_created_at(frontmatter["created_at"])
30
+
31
+ known_keys = %w[
32
+ id status title kind tags questions assignment step step_name
33
+ resume_instructions created_at answered answered_at
34
+ requester_provider requester_model requester_session_id
35
+ waiter_state waiter_session_id waiter_provider waiter_last_seen_at
36
+ waiter_poll_every_sec waiter_timeout_at
37
+ resume_dispatch_status resume_dispatch_attempted_at resumed_at resumed_by
38
+ resume_dispatch_error
39
+ ]
40
+ extra_metadata = frontmatter.reject { |k, _| known_keys.include?(k) }
41
+
42
+ Models::HitlEvent.new(
43
+ id: id || frontmatter["id"],
44
+ status: frontmatter["status"] || "pending",
45
+ kind: frontmatter["kind"] || "clarification",
46
+ title: title,
47
+ tags: Array(frontmatter["tags"]),
48
+ questions: Array(frontmatter["questions"]),
49
+ answer: answer,
50
+ content: body.to_s.strip,
51
+ path: dir_path,
52
+ file_path: hitl_file,
53
+ special_folder: special_folder,
54
+ created_at: created_at,
55
+ metadata: extra_metadata.merge(
56
+ "assignment" => frontmatter["assignment"],
57
+ "step" => frontmatter["step"],
58
+ "step_name" => frontmatter["step_name"],
59
+ "resume_instructions" => frontmatter["resume_instructions"],
60
+ "answered" => frontmatter["answered"],
61
+ "answered_at" => frontmatter["answered_at"],
62
+ "requester_provider" => frontmatter["requester_provider"],
63
+ "requester_model" => frontmatter["requester_model"],
64
+ "requester_session_id" => frontmatter["requester_session_id"],
65
+ "waiter_state" => frontmatter["waiter_state"],
66
+ "waiter_session_id" => frontmatter["waiter_session_id"],
67
+ "waiter_provider" => frontmatter["waiter_provider"],
68
+ "waiter_last_seen_at" => frontmatter["waiter_last_seen_at"],
69
+ "waiter_poll_every_sec" => frontmatter["waiter_poll_every_sec"],
70
+ "waiter_timeout_at" => frontmatter["waiter_timeout_at"],
71
+ "resume_dispatch_status" => frontmatter["resume_dispatch_status"],
72
+ "resume_dispatch_attempted_at" => frontmatter["resume_dispatch_attempted_at"],
73
+ "resumed_at" => frontmatter["resumed_at"],
74
+ "resumed_by" => frontmatter["resumed_by"],
75
+ "resume_dispatch_error" => frontmatter["resume_dispatch_error"]
76
+ ).compact
77
+ )
78
+ rescue SystemCallError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
79
+ warn "Warning: Failed to load HITL event from #{dir_path}: #{e.class}: #{e.message}"
80
+ nil
81
+ end
82
+
83
+ private
84
+
85
+ def extract_id(folder_name)
86
+ match = folder_name.match(/^([0-9a-z]{6})/)
87
+ match ? match[1] : nil
88
+ end
89
+
90
+ def extract_answer(body)
91
+ text = body.to_s
92
+ match = text.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)
93
+ return nil unless match
94
+
95
+ answer = match[1].to_s.strip
96
+ answer.empty? ? nil : answer
97
+ end
98
+
99
+ def parse_created_at(value)
100
+ return Time.now unless value
101
+
102
+ case value
103
+ when Time then value
104
+ when String then Time.parse(value)
105
+ else Time.now
106
+ end
107
+ rescue StandardError
108
+ Time.now
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+ require_relative "hitl_scanner"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Molecules
9
+ class HitlResolver
10
+ def initialize(root_dir)
11
+ @scanner = HitlScanner.new(root_dir)
12
+ end
13
+
14
+ def resolve(ref, warn_on_ambiguity: true)
15
+ scan_results = @scanner.scan
16
+ resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
17
+
18
+ on_ambiguity = if warn_on_ambiguity
19
+ ->(matches) {
20
+ ids = matches.map(&:id).join(", ")
21
+ warn "Warning: Ambiguous shortcut '#{ref}' matches #{matches.size} HITL events: #{ids}. Using most recent."
22
+ }
23
+ end
24
+
25
+ resolver.resolve(ref, on_ambiguity: on_ambiguity)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+ require_relative "../atoms/hitl_file_pattern"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Molecules
9
+ class HitlScanner
10
+ attr_reader :last_scan_total, :last_folder_counts
11
+
12
+ def initialize(root_dir)
13
+ @root_dir = root_dir
14
+ @scanner = Ace::Support::Items::Molecules::DirectoryScanner.new(
15
+ root_dir,
16
+ file_pattern: Atoms::HitlFilePattern::FILE_GLOB
17
+ )
18
+ end
19
+
20
+ def scan
21
+ @scanner.scan
22
+ end
23
+
24
+ def scan_in_folder(folder)
25
+ results = scan
26
+ @last_scan_total = results.size
27
+ @last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
28
+ return results if folder.nil?
29
+
30
+ virtual = Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(folder)
31
+ case virtual
32
+ when :all then results
33
+ when :next then results.select { |r| r.special_folder.nil? }
34
+ else
35
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(folder)
36
+ results.select { |r| r.special_folder == normalized }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Molecules
9
+ # Dispatches resume signals to waiting agents.
10
+ # Primary path is provider/session resume; fallback path executes resume instructions.
11
+ class ResumeDispatcher
12
+ Result = Struct.new(:success?, :mode, :details, :error, keyword_init: true)
13
+
14
+ def dispatch(event:, answer:, now: Time.now.utc)
15
+ session_id = event.metadata["requester_session_id"].to_s.strip
16
+ provider = event.metadata["requester_provider"].to_s.strip
17
+ instructions = event.metadata["resume_instructions"].to_s.strip
18
+
19
+ payload = build_payload(event: event, answer: answer, now: now)
20
+
21
+ if !session_id.empty? && !provider.empty?
22
+ resumed = dispatch_to_session(provider: provider, session_id: session_id, payload: payload)
23
+ return resumed if resumed.success?
24
+ end
25
+
26
+ return failed("No resume instructions available") if instructions.empty?
27
+
28
+ shell = run_shell(instructions)
29
+ return ok(mode: "command", details: "resume_instructions") if shell[:status].success?
30
+
31
+ failed("Resume command failed: #{stderr_or_stdout(shell)}")
32
+ rescue StandardError => e
33
+ failed("Resume dispatch exception: #{e.class}: #{e.message}")
34
+ end
35
+
36
+ private
37
+
38
+ def build_payload(event:, answer:, now:)
39
+ lines = []
40
+ lines << "HITL event answer received."
41
+ lines << "id: #{event.id}"
42
+ assignment = event.metadata["assignment"]
43
+ step = event.metadata["step"]
44
+ lines << "assignment: #{assignment}" if assignment
45
+ lines << "step: #{step}" if step
46
+ lines << "answered_at: #{now.iso8601}"
47
+ lines << ""
48
+ lines << "answer:"
49
+ lines << answer.to_s
50
+ lines << ""
51
+ resume = event.metadata["resume_instructions"].to_s
52
+ lines << "resume_instructions: #{resume}" unless resume.strip.empty?
53
+ lines.join("\n")
54
+ end
55
+
56
+ def dispatch_to_session(provider:, session_id:, payload:)
57
+ case provider
58
+ when /\Acodex\z/i
59
+ cmd = ["codex", "exec", "resume", session_id]
60
+ io = run_command(cmd, stdin_data: payload)
61
+ return ok(mode: "session", details: "codex:#{session_id}") if io[:status].success?
62
+ failed("Codex session resume failed: #{stderr_or_stdout(io)}")
63
+ when /\Aclaude\z/i
64
+ cmd = ["claude", "-p", "--resume", session_id]
65
+ io = run_command(cmd, stdin_data: payload)
66
+ return ok(mode: "session", details: "claude:#{session_id}") if io[:status].success?
67
+ failed("Claude session resume failed: #{stderr_or_stdout(io)}")
68
+ else
69
+ failed("Unsupported resume provider '#{provider}'")
70
+ end
71
+ end
72
+
73
+ def run_shell(command)
74
+ stdout, stderr, status = Open3.capture3("bash", "-lc", command.to_s)
75
+ {stdout: stdout, stderr: stderr, status: status}
76
+ end
77
+
78
+ def run_command(cmd, stdin_data: nil)
79
+ stdout, stderr, status = Open3.capture3(*cmd, stdin_data: stdin_data.to_s)
80
+ {stdout: stdout, stderr: stderr, status: status}
81
+ end
82
+
83
+ def stderr_or_stdout(io)
84
+ out = io[:stderr].to_s.strip
85
+ out = io[:stdout].to_s.strip if out.empty?
86
+ out.empty? ? "(no output)" : out
87
+ end
88
+
89
+ def ok(mode:, details:)
90
+ Result.new(success?: true, mode: mode, details: details, error: nil)
91
+ end
92
+
93
+ def failed(error)
94
+ Result.new(success?: false, mode: nil, details: nil, error: error)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Molecules
8
+ class WorktreeScopeResolver
9
+ VALID_SCOPES = %w[current all].freeze
10
+
11
+ def default_scope
12
+ main_checkout? ? "all" : "current"
13
+ end
14
+
15
+ def effective_scope(requested_scope)
16
+ requested_scope || default_scope
17
+ end
18
+
19
+ def current_worktree_root
20
+ @current_worktree_root ||= git_output("rev-parse", "--show-toplevel")
21
+ end
22
+
23
+ def worktree_roots(scope:)
24
+ case scope
25
+ when "current"
26
+ [current_worktree_root].compact
27
+ when "all"
28
+ roots = parse_worktree_list
29
+ roots = [current_worktree_root].compact if roots.empty?
30
+ roots
31
+ else
32
+ []
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def main_checkout?
39
+ current = current_worktree_root
40
+ common_root = git_common_root
41
+ return false if current.nil? || common_root.nil?
42
+
43
+ File.expand_path(current) == File.expand_path(common_root)
44
+ end
45
+
46
+ def parse_worktree_list
47
+ output = git_output("worktree", "list", "--porcelain")
48
+ return [] if output.nil? || output.empty?
49
+
50
+ roots = output.lines.filter_map do |line|
51
+ next unless line.start_with?("worktree ")
52
+
53
+ line.sub("worktree ", "").strip
54
+ end
55
+ roots.uniq
56
+ end
57
+
58
+ def git_common_root
59
+ common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir")
60
+ return nil if common_dir.nil? || common_dir.empty?
61
+
62
+ File.dirname(common_dir)
63
+ end
64
+
65
+ def git_output(*args)
66
+ stdout, status = Open3.capture2("git", *args)
67
+ return nil unless status.success?
68
+
69
+ output = stdout.to_s.strip
70
+ output.empty? ? nil : output
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end