hiiro 0.1.48 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60d88cfd824ceff5b2fc117392fee54e69b05bd831d3cab05cd4fea07f0152ab
4
- data.tar.gz: 2ee36dcee6939cf5e671617c53c4cc7cb052ae059df66d9cd0b1d147f38127c1
3
+ metadata.gz: ca5fd8cc7a86be8ac4dca336cc5283ce37eb788eb9d31bb1721ee6cdf339b40d
4
+ data.tar.gz: 8d781bbe42948a4a3143c23e2e560dbe6e756a8b6a40080810e458a12a2c2e43
5
5
  SHA512:
6
- metadata.gz: 2b25c56dc8a7d85db81b20609563f7929293f7437d56267f649a8a7582d0effa4228b7fcb262fc7cccf8c18c01375404b796fd27443b8a61bcd7d45bd9ece32b
7
- data.tar.gz: e3ec792efe3604380f859b22b9c14cb8749b4c76e1b3684d3e29afe24f2c2ad865d2d978bd1cca46ef6c42d63ee62190a51a1b8fce2a018b258d5e739ff64d93
6
+ metadata.gz: 36f287505cae5cf1dab0d39657c4ee2a3718e058298975843ef6e173a09070827c22320216d22b744a3858885dab49d527242f53767f4e498f1195a91b7c8f5b
7
+ data.tar.gz: 721e1a27f2de6207743e70aefbafa32d22724f4eb3d13af99a6bd9937ebf0306ea300cf15d4a2dffd253c528ec00d2a7dd0bf7b950c5b61c7de48b83662f3b89
data/bin/h-branch CHANGED
@@ -5,7 +5,7 @@ require "fileutils"
5
5
  require "yaml"
6
6
 
7
7
  Hiiro.load_env
8
- hiiro = Hiiro.init(*ARGV, plugins: [OldTask])
8
+ hiiro = Hiiro.init(*ARGV, plugins: [Tasks])
9
9
 
10
10
  class BranchManager
11
11
  attr_reader :hiiro
@@ -112,13 +112,13 @@ class BranchManager
112
112
  end
113
113
 
114
114
  def build_entry(branch_name)
115
- task_info = hiiro.task_manager.send(:current_task)
115
+ current_task = Environment.current.task
116
116
  tmux_info = capture_tmux_info
117
117
 
118
118
  {
119
119
  name: branch_name,
120
- worktree: task_info&.[](:tree),
121
- task: task_info&.[](:task),
120
+ worktree: current_task&.tree_name,
121
+ task: current_task&.name,
122
122
  tmux: tmux_info
123
123
  }
124
124
  end
data/bin/h-pr CHANGED
@@ -7,7 +7,7 @@ require "yaml"
7
7
  require "json"
8
8
 
9
9
  Hiiro.load_env
10
- hiiro = Hiiro.init(*ARGV, plugins: [OldTask, Tmux, Pins])
10
+ hiiro = Hiiro.init(*ARGV, plugins: [Tasks, Tmux, Pins])
11
11
 
12
12
  class PRManager
13
13
  attr_reader :hiiro
@@ -144,7 +144,7 @@ class PRManager
144
144
  end
145
145
 
146
146
  def build_entry(pr_info)
147
- task_info = hiiro.task_manager.send(:current_task)
147
+ current_task = Environment.current.task
148
148
  tmux_info = capture_tmux_info
149
149
 
150
150
  {
@@ -153,8 +153,8 @@ class PRManager
153
153
  url: pr_info['url'],
154
154
  branch: pr_info['headRefName'],
155
155
  state: pr_info['state'],
156
- worktree: task_info&.[](:tree),
157
- task: task_info&.[](:task),
156
+ worktree: current_task&.tree_name,
157
+ task: current_task&.name,
158
158
  tmux: tmux_info
159
159
  }
160
160
  end
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.48"
2
+ VERSION = "0.1.49"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.48
4
+ version: 0.1.49
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
@@ -75,7 +75,6 @@ files:
75
75
  - lib/hiiro/version.rb
76
76
  - notes
77
77
  - plugins/notify.rb
78
- - plugins/old_task.rb
79
78
  - plugins/pins.rb
80
79
  - plugins/project.rb
81
80
  - plugins/tasks.rb
data/plugins/old_task.rb DELETED
@@ -1,1156 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- module OldTask
4
- def self.load(hiiro)
5
- hiiro.load_plugin(Tmux)
6
- attach_methods(hiiro)
7
- add_subcommands(hiiro)
8
- end
9
-
10
- def self.add_subcommands(hiiro)
11
- hiiro.add_subcmd(:task) do |*args|
12
- tasks = hiiro.task_manager
13
-
14
- runner_map = {
15
- edit: ->(*sargs) { system(ENV['EDITOR'] || 'nvim', __FILE__) },
16
- list: ->(*sargs) { tasks.list_trees },
17
- ls: ->(*sargs) { tasks.list_trees },
18
- start: ->(task_name, tree=nil) { tasks.start_task(task_name, tree:) },
19
- switch: ->(task_name) { tasks.switch_task(task_name) },
20
- app: ->(*sargs) { tasks.open_app(*sargs) },
21
- path: ->(app_name=nil, task=nil) { tasks.app_path(app_name, task: task) },
22
- cd: ->(*sargs) { tasks.cd_app(*sargs) },
23
- apps: ->(*sargs) { tasks.list_configured_apps },
24
- status: ->(*sargs) { tasks.status },
25
- save: ->(*sargs) { tasks.save_current },
26
- stop: ->(*sargs) { tasks.stop_current },
27
- subtask: ->(*sargs) { tasks.handle_subtask(*sargs) },
28
- }
29
-
30
- case args
31
- in []
32
- tasks.help
33
- in ['edit']
34
- runner_map[:edit].call
35
- in ['list'] | ['ls']
36
- tasks.list_trees
37
- in ['start', task_name]
38
- tasks.start_task(task_name)
39
- in ['start', task_name, tree]
40
- tasks.start_task(task_name, tree: tree)
41
- in ['app', app_name]
42
- tasks.open_app(app_name)
43
- in ['apps']
44
- tasks.list_configured_apps
45
- in ['save']
46
- tasks.save_current
47
- in ['status'] | ['st']
48
- tasks.status
49
- in ['stop']
50
- tasks.stop_current
51
- in ['stop', task_name]
52
- tasks.stop_task(task_name)
53
- in ['subtask']
54
- tasks.subtask_help
55
- in ['subtask', 'ls']
56
- tasks.list_subtasks
57
- in ['subtask', 'new', subtask_name]
58
- tasks.new_subtask(subtask_name)
59
- in ['subtask', 'switch', subtask_name]
60
- tasks.switch_subtask(subtask_name)
61
- in ['subtask', 'switch']
62
- tasks.switch_subtask
63
- in [subcmd, *sargs]
64
- match = runner_map.keys.find { |full_subcmd| full_subcmd.to_s.start_with?(subcmd) }
65
-
66
- if match
67
- runner_map[match].call(*sargs)
68
- else
69
- puts "Unknown task subcommand: #{args.inspect}"
70
- tasks.help
71
- end
72
- else
73
- puts "Unknown task subcommand: #{args.inspect}"
74
- tasks.help
75
- end
76
- end
77
- end
78
-
79
- def self.attach_methods(hiiro)
80
- hiiro.instance_eval do
81
- def task_manager
82
- @task_manager ||= OldTask::TaskManager.new(self)
83
- end
84
- end
85
- end
86
-
87
- class TaskManager
88
- attr_reader :hiiro
89
-
90
- def initialize(hiiro)
91
- @hiiro = hiiro
92
- end
93
-
94
- def help
95
- puts "Usage: h task <subcommand> [args]"
96
- puts
97
- puts "Subcommands:"
98
- puts " list, ls List all worktrees and their active tasks"
99
- puts " start TASK Start a task (reuses available worktree or creates new)"
100
- puts " switch TASK Switch to an existing task"
101
- puts " app APP_NAME Open a tmux window for an app in current worktree"
102
- puts " apps List configured apps from apps.yml"
103
- puts " save Save current tmux session info for this task"
104
- puts " status, st Show current task status"
105
- puts " stop Stop working on current task (worktree becomes available)"
106
- puts " subtask <subcmd> Manage subtasks (ls, new, switch)"
107
- end
108
-
109
- # List all worktrees and their active tasks
110
- def list_trees
111
- puts "Git worktrees:"
112
- puts
113
-
114
- if trees.empty?
115
- puts " (no worktrees found)"
116
- puts
117
- puts " Start a task with 'h task start TASK_NAME' to create one."
118
- return
119
- end
120
-
121
- current = current_task
122
- active, available = trees.partition { |tree_name| task_for_tree(tree_name) }
123
-
124
- # Group active trees by parent task
125
- groups = {}
126
- active.each do |tree_name|
127
- task = task_for_tree(tree_name)
128
- parent = task.include?('/') ? task.split('/').first : task
129
- groups[parent] ||= []
130
- groups[parent] << { tree: tree_name, task: task }
131
- end
132
-
133
- first_group = true
134
- groups.each do |parent, entries|
135
- puts unless first_group
136
- first_group = false
137
-
138
- # Sort so the main entry (parent itself or parent/main) comes first
139
- entries.sort_by! { |e| e[:task] == parent || e[:task].end_with?('/main') ? 0 : 1 }
140
-
141
- entries.each_with_index do |entry, i|
142
- tree_name = entry[:tree]
143
- task = entry[:task]
144
- marker = (current && current[:tree] == tree_name) ? "*" : " "
145
- branch = worktree_branch(tree_name)
146
- branch_str = branch ? " [#{branch}]" : ""
147
-
148
- if i == 0
149
- # Parent task line
150
- display_name = parent
151
- puts format("%s %s%s", marker, display_name, branch_str)
152
- else
153
- # Subtask line: align /child_name under the parent name
154
- child_name = task.include?('/') ? task.split('/', 2).last : task
155
- padding = " " * parent.length
156
- puts format("%s %s/%s%s", marker, padding, child_name, branch_str)
157
- end
158
- end
159
- end
160
-
161
- if available.any?
162
- puts
163
- available.each do |tree_name|
164
- branch = worktree_branch(tree_name)
165
- branch_str = branch ? " [#{branch}]" : ""
166
- puts format(" %-20s (available)%s", tree_name, branch_str)
167
- end
168
- end
169
- end
170
-
171
- # Start working on a task
172
- def start_task(task_name, tree: nil)
173
- # Check if task already exists as a worktree
174
- existing_tree = tree_for_task(task_name)
175
- if existing_tree
176
- puts "Task '#{task_name}' already active in tree '#{existing_tree}'"
177
- puts "Switching to existing session..."
178
- switch_to_task(task_name, existing_tree)
179
- return true
180
- end
181
-
182
- # If a specific tree was requested, verify it exists
183
- if tree
184
- if !trees.include?(tree)
185
- puts "ERROR: Worktree '#{tree}' not found"
186
- return false
187
- end
188
- end
189
-
190
- # Find an available worktree to reuse, or create a new one
191
- available_tree = tree || find_available_tree
192
-
193
- # New tasks get a nested structure: task_name/main
194
- # so subtasks can live alongside as task_name/subtask_name
195
- subtree_name = "#{task_name}/main"
196
-
197
- if available_tree
198
- # Rename the available worktree to the task's main subtree
199
- old_path = tree_path(available_tree)
200
- new_path = File.join(Dir.home, 'work', subtree_name)
201
-
202
- if available_tree != subtree_name
203
- puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
204
- FileUtils.mkdir_p(File.dirname(new_path))
205
- result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
206
- unless result
207
- puts "ERROR: Failed to rename worktree"
208
- return false
209
- end
210
- clear_worktree_cache
211
- end
212
-
213
- final_tree_name = subtree_name
214
- final_tree_path = new_path
215
- else
216
- # No available worktree, create a new one
217
- puts "Creating new worktree for '#{task_name}'..."
218
- new_path = File.join(Dir.home, 'work', subtree_name)
219
-
220
- # Create worktree from main branch (detached to avoid branch conflicts)
221
- FileUtils.mkdir_p(File.dirname(new_path))
222
- result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
223
- unless result
224
- puts "ERROR: Failed to create worktree"
225
- return false
226
- end
227
- clear_worktree_cache
228
-
229
- final_tree_name = subtree_name
230
- final_tree_path = new_path
231
- end
232
-
233
- # Associate task with tree
234
- assign_task_to_tree(task_name, final_tree_name)
235
-
236
- # Create/switch to tmux session
237
- session_name = session_name_for(task_name)
238
-
239
- Dir.chdir(final_tree_path)
240
- hiiro.start_tmux_session(session_name)
241
-
242
- save_task_metadata(task_name, tree: final_tree_name, session: session_name)
243
-
244
- puts "Started task '#{task_name}' in worktree '#{final_tree_name}'"
245
- true
246
- end
247
-
248
- # Start working on a task
249
- def switch_task(task_name)
250
- tree, task = assignments.find { |tree, task| task.start_with?(task_name) } || []
251
-
252
- unless task
253
- puts "No task matching #{task_name} found."
254
- return false
255
- end
256
-
257
- switch_to_task(task, tree)
258
-
259
- puts "Started task '#{task}' in tree '#{tree}'"
260
- true
261
- end
262
-
263
- # Open an app window within the current tree
264
- def open_app(app_name)
265
- current = current_task
266
- unless current
267
- puts "ERROR: Not currently in a task session"
268
- puts "Use 'h task start TASK_NAME' first"
269
- return false
270
- end
271
-
272
- tree = current[:tree]
273
- result = find_app_path(tree, app_name)
274
-
275
- case result
276
- in nil
277
- puts "ERROR: App '#{app_name}' not found"
278
- puts
279
- list_apps(tree)
280
- return false
281
- in [:ambiguous, matches]
282
- puts "ERROR: '#{app_name}' matches multiple apps:"
283
- matches.each { |m| puts " #{m}" }
284
- puts
285
- puts "Be more specific."
286
- return false
287
- in [resolved_name, app_path]
288
- # Create new tmux window with app directory as base
289
- system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
290
- puts "Opened '#{resolved_name}' in new window (#{app_path})"
291
- true
292
- end
293
- end
294
-
295
- # Open an app window within the current tree
296
- def cd_app(app_name=nil)
297
- current = current_task
298
- unless current
299
- puts "ERROR: Not currently in a task session"
300
- puts "Use 'h task start TASK_NAME' first"
301
- return false
302
- end
303
-
304
- tree = current[:tree]
305
-
306
- result = []
307
- if app_name.to_s == ''
308
- result = ['root', tree_path(tree)]
309
- else
310
- result = find_app_path(tree, app_name)
311
- end
312
-
313
- case result
314
- in nil
315
- puts "ERROR: App '#{app_name}' not found"
316
- puts
317
- list_apps(tree)
318
- return false
319
- in [:ambiguous, matches]
320
- puts "ERROR: '#{app_name}' matches multiple apps:"
321
- matches.each { |m| puts " #{m}" }
322
- puts
323
- puts "Be more specific."
324
- return false
325
- in [resolved_name, app_path]
326
- # Create new tmux window with app directory as base
327
- pane = ENV['TMUX_PANE']
328
- if pane
329
- puts "PANE: #{pane}"
330
- puts command: ['tmux', 'send-keys', '-t', pane, "cd #{app_path}\n"].join(' ')
331
- system('tmux', 'send-keys', '-t', pane, "cd #{app_path}\n")
332
- else
333
- puts command: ['tmux', 'send-keys', "cd #{app_path}\n"].join(' ')
334
- system('tmux', 'send-keys', "cd #{app_path}\n")
335
- end
336
- puts "Opened '#{resolved_name}' in new window (#{app_path})"
337
- true
338
- end
339
- end
340
-
341
- # Save current tmux session state
342
- def save_current
343
- current = current_task
344
- unless current
345
- puts "ERROR: Not currently in a task session"
346
- return false
347
- end
348
-
349
- task_name = current[:task]
350
- tree = current[:tree]
351
- session = current[:session]
352
-
353
- # Capture tmux window info
354
- windows = capture_tmux_windows(session)
355
-
356
- save_task_metadata(task_name,
357
- tree: tree,
358
- session: session,
359
- windows: windows,
360
- saved_at: Time.now.iso8601
361
- )
362
-
363
- puts "Saved task '#{task_name}' state (#{windows.count} windows)"
364
- true
365
- end
366
-
367
- # Show current task status
368
- def status
369
- current = current_task
370
- unless current
371
- puts "Not currently in a task session"
372
- return
373
- end
374
-
375
- puts "Current task: #{current[:task]}"
376
- puts "Worktree: #{current[:tree]}"
377
- puts "Path: #{tree_path(current[:tree])}"
378
- puts "Session: #{current[:session]}"
379
-
380
- meta = task_metadata(current[:task])
381
- if meta && meta['saved_at']
382
- puts "Last saved: #{meta['saved_at']}"
383
- end
384
- end
385
-
386
- # Stop working on current task (disassociate from worktree)
387
- def stop_current
388
- current = current_task
389
- unless current
390
- puts "Not currently in a task session"
391
- return false
392
- end
393
-
394
- stop_task(current[:task])
395
- end
396
-
397
- # Stop working on a task (disassociate from worktree)
398
- def stop_task(task_name)
399
- tree = tree_for_task(task_name)
400
- task_name = task_for_tree(tree)
401
-
402
- unassign_task_from_tree(tree)
403
- puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
404
- true
405
- end
406
-
407
- # Subtask management
408
- def subtask_help
409
- puts "Usage: h task subtask <subcommand> [args]"
410
- puts " h subtask <subcommand> [args]"
411
- puts
412
- puts "Subcommands:"
413
- puts " list, ls List subtasks for current task"
414
- puts " new SUBTASK_NAME Start a new subtask (creates worktree and session)"
415
- puts " switch SUBTASK_NAME Switch to subtask's tmux session"
416
- puts " app APP_NAME Open a tmux window for an app in current subtask"
417
- puts " apps List configured apps from apps.yml"
418
- puts " cd [APP_NAME] Change directory to app in current subtask"
419
- puts " path [APP_NAME] Print app path in current subtask"
420
- puts " status, st Show current subtask status"
421
- puts " save Save current subtask session info"
422
- puts " stop [SUBTASK_NAME] Stop working on current/named subtask"
423
- puts " current Print current subtask name"
424
- end
425
-
426
- def handle_subtask(*args)
427
- case args
428
- in []
429
- subtask_help
430
- in ['list'] | ['ls']
431
- list_subtasks
432
- in ['new', subtask_name]
433
- new_subtask(subtask_name)
434
- in ['switch']
435
- switch_subtask
436
- in ['switch', subtask_name]
437
- switch_subtask(subtask_name)
438
- in ['app', app_name]
439
- subtask_open_app(app_name)
440
- in ['apps']
441
- list_configured_apps
442
- in ['cd', *cd_args]
443
- subtask_cd_app(*cd_args)
444
- in ['path']
445
- subtask_app_path
446
- in ['path', app_name]
447
- subtask_app_path(app_name)
448
- in ['status'] | ['st']
449
- subtask_status
450
- in ['save']
451
- save_subtask
452
- in ['stop']
453
- stop_subtask
454
- in ['stop', subtask_name]
455
- stop_subtask(subtask_name)
456
- in ['current']
457
- print current_subtask_name
458
- else
459
- puts "Unknown subtask command: #{args.inspect}"
460
- subtask_help
461
- end
462
- end
463
-
464
- def list_subtasks
465
- current = current_task
466
- unless current
467
- puts "ERROR: Not currently in a task session"
468
- return false
469
- end
470
-
471
- parent_task = current[:task]
472
- # Handle if we're in a subtask - get the parent
473
- if parent_task.include?('/')
474
- parent_task = parent_task.split('/').first
475
- end
476
- subtasks = subtasks_for_task(parent_task)
477
-
478
- puts "Subtasks for '#{parent_task}':"
479
- puts
480
-
481
- # Always show the parent (main) task first
482
- parent_tree = tree_for_task(parent_task)
483
- current_tree = current[:tree]
484
- parent_marker = (parent_tree && parent_tree == current_tree) ? "*" : " "
485
- parent_branch = worktree_branch(parent_tree) if parent_tree
486
- branch_info = parent_branch ? " branch: #{parent_branch}" : ""
487
- puts format("%s %-25s tree: %-15s%s", parent_marker, "(main)", parent_tree || '(none)', branch_info)
488
-
489
- subtasks.each do |subtask|
490
- marker = (subtask['worktree'] == current_tree) ? "*" : " "
491
- st_branch = worktree_branch(subtask['worktree']) if subtask['worktree']
492
- st_branch_info = st_branch ? " branch: #{st_branch}" : ""
493
- puts format("%s %-25s tree: %-15s created: %s%s",
494
- marker,
495
- subtask['name'],
496
- subtask['worktree'] || '(none)',
497
- subtask['created_at']&.split('T')&.first || '?',
498
- st_branch_info
499
- )
500
- end
501
- end
502
-
503
- def new_subtask(subtask_name)
504
- current = current_task
505
- unless current
506
- puts "ERROR: Not currently in a task session"
507
- puts "Use 'h task start TASK_NAME' first"
508
- return false
509
- end
510
-
511
- parent_task = current[:task]
512
-
513
- if parent_task.include?('/')
514
- parent_task = parent_task.split('/').first
515
- end
516
-
517
- parent_dir =
518
- if worktree_info.key?(parent_task)
519
- "#{parent_task}_subtasks"
520
- else
521
- parent_task
522
- end
523
- full_subtask_name = "#{parent_dir}/#{subtask_name}"
524
-
525
- # Check if subtask already exists
526
- existing = subtasks_for_task(parent_task).find { |s| s['name'] == subtask_name }
527
- if existing
528
- puts "Subtask '#{subtask_name}' already exists for task '#{parent_task}'"
529
- puts "Switching to existing session..."
530
- switch_subtask(subtask_name)
531
- return true
532
- end
533
-
534
- # Create new worktree for subtask
535
- puts "Creating new worktree for subtask '#{subtask_name}'..."
536
- new_path = File.join(Dir.home, 'work', full_subtask_name)
537
-
538
- result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
539
- unless result
540
- puts "ERROR: Failed to create worktree"
541
- return false
542
- end
543
- clear_worktree_cache
544
-
545
- # Associate subtask with tree
546
- assign_task_to_tree(full_subtask_name, full_subtask_name)
547
-
548
- # Record subtask in parent's metadata
549
- add_subtask_to_task(parent_task, {
550
- 'name' => subtask_name,
551
- 'worktree' => full_subtask_name,
552
- 'session' => full_subtask_name,
553
- 'created_at' => Time.now.iso8601,
554
- 'active' => true
555
- })
556
-
557
- # Create/switch to tmux session
558
- Dir.chdir(new_path)
559
- hiiro.start_tmux_session(full_subtask_name)
560
-
561
- puts "Started subtask '#{subtask_name}' for task '#{parent_task}'"
562
- true
563
- end
564
-
565
- def switch_subtask(subtask_name = nil)
566
- current = current_task
567
- unless current
568
- puts "ERROR: Not currently in a task session"
569
- puts "Use 'h task start TASK_NAME' first"
570
- return false
571
- end
572
-
573
- parent_task = current[:task]
574
- # Handle if we're in a subtask - get the parent
575
- if parent_task.include?('/')
576
- parent_task = parent_task.split('/').first
577
- end
578
-
579
- # If no subtask name, use interactive selection
580
- if subtask_name.nil? || subtask_name.empty?
581
- subtask_name = select_subtask_interactive(parent_task)
582
- return false unless subtask_name
583
- end
584
-
585
- # Switch to parent task if "main" or "parent"
586
- if subtask_name == 'main' || subtask_name == 'parent'
587
- session_name = session_name_for(parent_task)
588
- tree_name = tree_for_task(parent_task)
589
-
590
- unless tree_name
591
- puts "ERROR: Parent task '#{parent_task}' has no worktree"
592
- return false
593
- end
594
-
595
- session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
596
-
597
- if session_exists
598
- hiiro.start_tmux_session(session_name)
599
- else
600
- path = tree_path(tree_name)
601
- if Dir.exist?(path)
602
- Dir.chdir(path)
603
- hiiro.start_tmux_session(session_name)
604
- else
605
- puts "ERROR: Worktree path '#{path}' does not exist"
606
- return false
607
- end
608
- end
609
-
610
- puts "Switched to parent task '#{parent_task}'"
611
- return true
612
- end
613
-
614
- subtask = find_subtask(parent_task, subtask_name)
615
- unless subtask
616
- puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
617
- puts
618
- list_subtasks
619
- return false
620
- end
621
-
622
- session_name = subtask['session'] || "#{parent_task}/#{subtask_name}"
623
- tree_name = subtask['worktree'] || session_name
624
-
625
- # Check if session exists
626
- session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
627
-
628
- if session_exists
629
- hiiro.start_tmux_session(session_name)
630
- else
631
- # Create new session in the worktree path
632
- path = tree_path(tree_name)
633
- if Dir.exist?(path)
634
- Dir.chdir(path)
635
- hiiro.start_tmux_session(session_name)
636
- else
637
- puts "ERROR: Worktree path '#{path}' does not exist"
638
- return false
639
- end
640
- end
641
-
642
- puts "Switched to subtask '#{subtask_name}'"
643
- true
644
- end
645
-
646
- # Interactive subtask selection using sk
647
- def select_subtask_interactive(parent_task)
648
- subtasks = subtasks_for_task(parent_task)
649
- parent_tree = tree_for_task(parent_task)
650
-
651
- # Build selection lines: include parent (main) and all subtasks
652
- lines = []
653
- lines << "(main)" if parent_tree
654
- subtasks.each do |subtask|
655
- next unless subtask['active']
656
- lines << subtask['name']
657
- end
658
-
659
- if lines.empty?
660
- puts "No subtasks to switch to."
661
- return nil
662
- end
663
-
664
- choice = Hiiro::Sk.select(lines)
665
- return nil unless choice
666
- return 'main' if choice == '(main)'
667
-
668
- choice
669
- end
670
-
671
- # Show status for the current subtask
672
- def subtask_status
673
- current = current_task
674
- unless current
675
- puts "Not currently in a task session"
676
- return
677
- end
678
-
679
- parent_task = parent_task_name(current[:task])
680
- subtask_name = current_subtask_name_from(current[:task])
681
-
682
- puts "Parent task: #{parent_task}"
683
- if subtask_name
684
- puts "Subtask: #{subtask_name}"
685
- else
686
- puts "Subtask: (main)"
687
- end
688
- puts "Worktree: #{current[:tree]}"
689
- puts "Path: #{tree_path(current[:tree])}"
690
- puts "Session: #{current[:session]}"
691
- end
692
-
693
- # Save current subtask tmux session state
694
- def save_subtask
695
- current = current_task
696
- unless current
697
- puts "ERROR: Not currently in a task session"
698
- return false
699
- end
700
-
701
- parent_task = parent_task_name(current[:task])
702
- session = current[:session]
703
-
704
- windows = capture_tmux_windows(session)
705
-
706
- save_task_metadata(parent_task,
707
- tree: current[:tree],
708
- session: session,
709
- windows: windows,
710
- saved_at: Time.now.iso8601
711
- )
712
-
713
- puts "Saved subtask session state (#{windows.count} windows)"
714
- true
715
- end
716
-
717
- # Stop a subtask (remove from parent's subtask list, unassign tree)
718
- def stop_subtask(subtask_name = nil)
719
- current = current_task
720
- unless current
721
- puts "Not currently in a task session"
722
- return false
723
- end
724
-
725
- parent_task = parent_task_name(current[:task])
726
-
727
- if subtask_name.nil?
728
- # Stop the current subtask
729
- subtask_name = current_subtask_name_from(current[:task])
730
- unless subtask_name
731
- puts "Not currently in a subtask (in main task)"
732
- return false
733
- end
734
- end
735
-
736
- subtask = find_subtask(parent_task, subtask_name)
737
- unless subtask
738
- puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
739
- return false
740
- end
741
-
742
- tree_name = subtask['worktree']
743
- if tree_name
744
- unassign_task_from_tree(tree_name)
745
- end
746
-
747
- # Mark subtask as inactive in parent metadata
748
- meta = task_metadata(parent_task) || {}
749
- if meta['subtasks']
750
- meta['subtasks'].each do |s|
751
- if s['name'].start_with?(subtask_name)
752
- s['active'] = false
753
- end
754
- end
755
- FileUtils.mkdir_p(task_dir)
756
- File.write(task_metadata_file(parent_task), YAML.dump(meta))
757
- end
758
-
759
- puts "Stopped subtask '#{subtask_name}' (worktree available for reuse)"
760
- true
761
- end
762
-
763
- # Print the current subtask name
764
- def current_subtask_name
765
- current = current_task
766
- return nil unless current
767
-
768
- subtask_name = current_subtask_name_from(current[:task])
769
- subtask_name || current[:task]
770
- end
771
-
772
- # Open an app window scoped to the current subtask's tree
773
- def subtask_open_app(app_name)
774
- open_app(app_name)
775
- end
776
-
777
- # cd to app in current subtask's tree
778
- def subtask_cd_app(*args)
779
- cd_app(*args)
780
- end
781
-
782
- # Print app path in current subtask's tree
783
- def subtask_app_path(app_name = nil, task: nil)
784
- app_path(app_name, task: task || current_task&.dig(:task))
785
- end
786
-
787
- private
788
-
789
- def subtasks_for_task(task_name)
790
- meta = task_metadata(task_name)
791
- return [] unless meta
792
- meta['subtasks'] || []
793
- end
794
-
795
- def find_subtask(parent_task, subtask_name)
796
- subtasks = subtasks_for_task(parent_task)
797
- subtasks.find { |s| s['name'].start_with?(subtask_name) }
798
- end
799
-
800
- def add_subtask_to_task(parent_task, subtask_data)
801
- meta = task_metadata(parent_task) || {}
802
- meta['subtasks'] ||= []
803
- meta['subtasks'] << subtask_data
804
- FileUtils.mkdir_p(task_dir)
805
- File.write(task_metadata_file(parent_task), YAML.dump(meta))
806
- end
807
-
808
- # Extract parent task name from a potentially nested task/subtask name
809
- def parent_task_name(task_name)
810
- task_name.include?('/') ? task_name.split('/').first : task_name
811
- end
812
-
813
- # Extract subtask name from a nested task name (returns nil if main)
814
- def current_subtask_name_from(task_name)
815
- return nil unless task_name.include?('/')
816
- parts = task_name.split('/', 2)
817
- parts.last == 'main' ? nil : parts.last
818
- end
819
-
820
- public
821
-
822
- def list_configured_apps
823
- if apps_config.any?
824
- puts "Configured apps (#{apps_config_file}):"
825
- puts
826
- apps_config.each do |name, path|
827
- puts format(" %-20s => %s", name, path)
828
- end
829
- else
830
- puts "No apps configured."
831
- puts
832
- puts "Create #{apps_config_file} with format:"
833
- puts " app_name: relative/path/from/repo"
834
- puts
835
- puts "Example:"
836
- puts " partners: partners/partners"
837
- puts " admin: admin_portal/admin"
838
- end
839
- end
840
-
841
- def app_path(app_name, task: nil)
842
- tree_root = task ? tree_path(tree_for_task(task)) : `git rev-parse --show-toplevel`.strip
843
-
844
- if app_name.nil?
845
- print tree_root
846
- exit 0
847
- end
848
-
849
- matching_apps = find_all_apps(app_name)
850
- longest_app_name = printable_apps.keys.max_by(&:length).length + 2
851
-
852
- case matching_apps.count
853
- when 0
854
- puts "ERROR: No matches found"
855
- puts
856
- puts "Possible Apps:"
857
- puts printable_apps.keys.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
858
- exit 1
859
- when 1
860
- print File.join(tree_root, apps_config[matching_apps.first])
861
- exit 0
862
- else
863
- puts "Multiple matches found:"
864
- puts matching_apps.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
865
- exit 1
866
- end
867
- end
868
-
869
- private
870
-
871
- def printable_apps
872
- apps_config.transform_keys(&:to_s)
873
- end
874
-
875
- # Find worktrees using git worktree list
876
- def trees
877
- worktree_info.keys.sort
878
- end
879
-
880
- # Parse git worktree list output into { name => { path:, branch: } } hash
881
- def worktree_details
882
- @worktree_details ||= begin
883
- output = `git -C #{main_repo_path} worktree list --porcelain 2>/dev/null`
884
- details = {}
885
- current_path = nil
886
-
887
- work_dir = File.join(Dir.home, 'work')
888
- work_prefix = work_dir + '/'
889
-
890
- output.lines.each do |line|
891
- line = line.strip
892
- if line.start_with?('worktree ')
893
- current_path = line.sub('worktree ', '')
894
- elsif line == 'bare'
895
- # Skip bare repo
896
- current_path = nil
897
- elsif line.start_with?('branch ') || line == 'detached'
898
- branch = if line.start_with?('branch ')
899
- line.sub('branch refs/heads/', '')
900
- else
901
- '(detached)'
902
- end
903
-
904
- # Capture worktree (both named branches and detached HEAD)
905
- if current_path && current_path != main_repo_path
906
- # Use relative path from ~/work/ to support nested worktrees
907
- # e.g. ~/work/my-task/main -> "my-task/main"
908
- name = if current_path.start_with?(work_prefix)
909
- current_path.sub(work_prefix, '')
910
- else
911
- File.basename(current_path)
912
- end
913
- details[name] = { path: current_path, branch: branch }
914
- end
915
- current_path = nil
916
- end
917
- end
918
-
919
- details
920
- end
921
- end
922
-
923
- # Backward-compatible { name => path } hash
924
- def worktree_info
925
- @worktree_info ||= worktree_details.transform_values { |v| v[:path] }
926
- end
927
-
928
- # Get branch name for a worktree
929
- def worktree_branch(tree_name)
930
- worktree_details.dig(tree_name, :branch)
931
- end
932
-
933
- def clear_worktree_cache
934
- @worktree_details = nil
935
- @worktree_info = nil
936
- end
937
-
938
- # Get the main repo path (where we run git worktree commands from)
939
- def main_repo_path
940
- File.join(Dir.home, 'work', '.bare')
941
- end
942
-
943
- def tree_path(tree_name)
944
- worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
945
- end
946
-
947
- # Find an available tree (one without an active task)
948
- def find_available_tree
949
- trees.find { |tree| task_for_tree(tree).nil? }
950
- end
951
-
952
- # Get the task currently assigned to a tree
953
- def task_for_tree(tree_name)
954
- assignments[tree_name]
955
- end
956
-
957
- # Get the tree a task is assigned to
958
- def tree_for_task(task_name)
959
- assignment_for_task(task_name)&.first
960
- end
961
-
962
- def assignment_for_task(partial)
963
- assignments.find { |tree, task| task.start_with?(partial) }
964
- end
965
-
966
- def find_task(partial)
967
- assignments.values.find { |task| task.start_with?(partial) }
968
- end
969
-
970
- # Assign a task to a tree
971
- def assign_task_to_tree(task_name, tree_name)
972
- data = assignments
973
- data[tree_name] = task_name
974
- save_assignments(data)
975
- end
976
-
977
- # Unassign task from tree
978
- def unassign_task_from_tree(tree_name)
979
- data = assignments.dup
980
- data.delete(tree_name)
981
- save_assignments(data)
982
- end
983
-
984
- # Tree -> Task assignments
985
- def assignments
986
- @assignments ||= load_assignments
987
- end
988
-
989
- def load_assignments
990
- data = if File.exist?(assignments_file)
991
- YAML.safe_load_file(assignments_file) || {}
992
- else
993
- {}
994
- end
995
- data
996
- end
997
-
998
- def save_assignments(data)
999
- FileUtils.mkdir_p(task_dir)
1000
- File.write(assignments_file, YAML.dump(data))
1001
- @assignments = data
1002
- end
1003
-
1004
- def assignments_file
1005
- File.join(task_dir, 'assignments.yml')
1006
- end
1007
-
1008
- # Task metadata
1009
- def task_metadata(task_name)
1010
- file = task_metadata_file(task_name)
1011
- return nil unless File.exist?(file)
1012
- YAML.safe_load_file(file)
1013
- end
1014
-
1015
- def save_task_metadata(task_name, **data)
1016
- FileUtils.mkdir_p(task_dir)
1017
- existing = task_metadata(task_name) || {}
1018
- merged = existing.merge(data.transform_keys(&:to_s))
1019
- File.write(task_metadata_file(task_name), YAML.dump(merged))
1020
- end
1021
-
1022
- def task_metadata_file(task_name)
1023
- safe_name = task_name.gsub(/[^a-zA-Z0-9_-]/, '_')
1024
- File.join(task_dir, "task_#{safe_name}.yml")
1025
- end
1026
-
1027
- def task_dir
1028
- File.join(Dir.home, '.config', 'hiiro', 'tasks')
1029
- end
1030
-
1031
- # Session name for a task
1032
- def session_name_for(task_name)
1033
- task_name
1034
- end
1035
-
1036
- # Detect current task from tmux session name
1037
- def current_task
1038
- return nil unless ENV['TMUX']
1039
-
1040
- session = `tmux display-message -p '#S'`.strip
1041
-
1042
- task_name = session
1043
- tree = tree_for_task(task_name)
1044
-
1045
- return nil unless tree
1046
-
1047
- { task: task_name, tree: tree, session: session }
1048
- end
1049
-
1050
- def switch_to_task(task_name, tree)
1051
- session = session_name_for(task_name)
1052
- tree_path = tree_path(tree)
1053
- Dir.chdir(tree_path)
1054
- hiiro.start_tmux_session(session)
1055
- end
1056
-
1057
- # Apps config from ~/.config/hiiro/apps.yml
1058
- # Format: { "app_name" => "relative/path/from/repo/root" }
1059
- def apps_config
1060
- @apps_config ||= load_apps_config
1061
- end
1062
-
1063
- def load_apps_config
1064
- return {} unless File.exist?(apps_config_file)
1065
- YAML.safe_load_file(apps_config_file) || {}
1066
- end
1067
-
1068
- def apps_config_file
1069
- File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
1070
- end
1071
-
1072
- # Find app by partial match (must be unique)
1073
- def find_app(partial)
1074
- matches = find_all_apps(partial)
1075
-
1076
- case matches.count
1077
- when 0
1078
- nil
1079
- when 1
1080
- matches.first
1081
- else
1082
- # Check for exact match among multiple partial matches
1083
- exact = matches.find { |name| name == partial }
1084
- exact ? [exact] : matches
1085
- end
1086
- end
1087
-
1088
- def find_all_apps(partial)
1089
- apps_config.keys.select { |name| name.start_with?(partial) }
1090
- end
1091
-
1092
- # App discovery within a tree
1093
- def find_app_path(tree, app_name)
1094
- tree_root = tree_path(tree)
1095
-
1096
- # First, check apps.yml config
1097
- result = find_app(app_name)
1098
-
1099
- case result
1100
- when String
1101
- # Single match - use configured path
1102
- return [result, File.join(tree_root, apps_config[result])]
1103
- when Array
1104
- # Multiple matches - return them for error reporting
1105
- return [:ambiguous, result]
1106
- end
1107
-
1108
- # Fallback: directory discovery if not in config
1109
- # Look for exact match first
1110
- exact = File.join(tree_root, app_name)
1111
- return [app_name, exact] if Dir.exist?(exact)
1112
-
1113
- # Look for nested app dirs (monorepo pattern: app/app)
1114
- nested = File.join(tree_root, app_name, app_name)
1115
- return [app_name, nested] if Dir.exist?(nested)
1116
-
1117
- # Fuzzy match on directories
1118
- pattern = File.join(tree_root, '*')
1119
- match = Dir.glob(pattern).find { |path|
1120
- File.basename(path).start_with?(app_name) && File.directory?(path)
1121
- }
1122
- return [File.basename(match), match] if match
1123
-
1124
- nil
1125
- end
1126
-
1127
- def list_apps(tree)
1128
- if apps_config.any?
1129
- puts "Configured apps (from apps.yml):"
1130
- apps_config.each do |name, path|
1131
- puts format(" %-20s => %s", name, path)
1132
- end
1133
- else
1134
- puts "No apps configured. Create ~/.config/hiiro/apps.yml"
1135
- puts "Format:"
1136
- puts " app_name: relative/path/from/repo"
1137
- puts
1138
- puts "Directories in tree:"
1139
- tree_root = tree_path(tree)
1140
- pattern = File.join(tree_root, '*')
1141
- Dir.glob(pattern).select { |p| File.directory?(p) }.each do |path|
1142
- puts " #{File.basename(path)}"
1143
- end
1144
- end
1145
- end
1146
-
1147
- # Capture tmux window state
1148
- def capture_tmux_windows(session)
1149
- output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}'`
1150
- output.lines.map(&:strip).map { |line|
1151
- idx, name, path = line.split(':')
1152
- { 'index' => idx, 'name' => name, 'path' => path }
1153
- }
1154
- end
1155
- end
1156
- end