hiiro 0.1.27 → 0.1.29

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: 50b6c4372984b628ce714e2a29249f93f8f869af5298752b5983bc9d18edebcf
4
- data.tar.gz: 9477252f59c4afcd35dca01b6b576a233c7352c6f40d640b5f019609287655f7
3
+ metadata.gz: 9bb0956fd77df5e3e941a772cedc2fb51376f7be1adb7ce40b217d73a300d058
4
+ data.tar.gz: 9436e79dae25ae352dcadfbea5e3dcaf1e3be6d435fc4ea5823b71e7425fc739
5
5
  SHA512:
6
- metadata.gz: bc6efdae4f2a2b948436dc7551c8acd89098c21a42e53c53b5bb5034a10679dc34f680fb5cc0e6361226efce00ed4bafd95a9776eeda3582147d1c41ac8c7c82
7
- data.tar.gz: 542dcc0dbf7a72c8b063c38bc02cf629a60522549a71dbdb44c20bca9c6de20a40d294c4fa417140436403fa5a6bb0fb40330399e8e8e45249dd5343a03aef83
6
+ metadata.gz: 3b84f6e8bb918e7029af357a84bf4debe20a2467e9be85d77ea5e931e1b8ac64097c45b9d29b88e8db6514b7303798ef051adcb88144ff7e534c6ab0b2f4bbab
7
+ data.tar.gz: d901ce7695933da9452d7a08cd95cdf28b706af8e46c34eb68dbc0d4500ce7a8b4d0a479fe5b58e8201afe55131b6aa6249d682ef166632a819e11a878440b5a
data/README.md CHANGED
@@ -69,6 +69,78 @@ h ping
69
69
  # => pong
70
70
  ```
71
71
 
72
+ ## Subcommands
73
+
74
+ ### Base Commands
75
+
76
+ | Command | Description |
77
+ |---------|-------------|
78
+ | `h version` | Display the Hiiro version |
79
+ | `h ping` | Simple test command (returns "pong") |
80
+ | `h setup` | Install plugins and subcommands to system paths |
81
+ | `h edit` | Open the h script in your editor |
82
+ | `h path` | Print the current directory |
83
+ | `h ppath` | Print project path (git root + relative dir) |
84
+ | `h rpath` | Print relative path from git root |
85
+ | `h pin` | Per-command key-value storage (via Pins plugin) |
86
+ | `h project` | Project navigation with tmux integration (via Project plugin) |
87
+ | `h task` | Task management across git worktrees (via Task plugin) |
88
+ | `h notify` | macOS desktop notifications via terminal-notifier (via Notify plugin) |
89
+
90
+ ### External Subcommands
91
+
92
+ | Command | Description |
93
+ |---------|-------------|
94
+ | `h branch` | Record and manage git branch history for tasks |
95
+ | `h buffer` | Tmux paste buffer management |
96
+ | `h dot` | Compare directories and generate symlink/diff commands |
97
+ | `h dotfiles` | Manage dotfiles in ~/proj/home |
98
+ | `h home` | Manage home directory files with edit and search |
99
+ | `h html` | Generate an HTML index of MP4 videos in current directory |
100
+ | `h link` | Manage saved links with URL, description, and shorthand |
101
+ | `h mic` | Control macOS microphone input volume |
102
+ | `h note` | Create, edit, list, and display notes |
103
+ | `h pane` | Tmux pane management |
104
+ | `h plugin` | Manage hiiro plugins (list, edit, search) |
105
+ | `h pr` | Record PR information linked to tasks |
106
+ | `h pr-monitor` | Monitor pull requests |
107
+ | `h pr-watch` | Watch pull requests for updates |
108
+ | `h project` | Open projects with tmux session management |
109
+ | `h runtask` | Run templated task scripts |
110
+ | `h serve` | Start a miniserve HTTP server on port 1111 |
111
+ | `h session` | Tmux session management |
112
+ | `h sha` | Extract short SHA from git log |
113
+ | `h subtask` | Shorthand for task subtask management |
114
+ | `h task` | Comprehensive task manager for git worktrees |
115
+ | `h video` | Video inspection and operations via ffprobe/ffmpeg |
116
+ | `h vim` | Manage nvim configuration with edit and search |
117
+ | `h window` | Tmux window management |
118
+ | `h wtree` | Git worktree management |
119
+
120
+ ## Abbreviations
121
+
122
+ Any subcommand can be abbreviated as long as the prefix uniquely matches:
123
+
124
+ ```sh
125
+ h ex hel # matches h example hello
126
+ h te # matches h test (if unique)
127
+ h pp # matches h ppath
128
+ ```
129
+
130
+ If multiple commands match, the first match wins and a warning is logged (when logging is enabled).
131
+
132
+ ## Plugins
133
+
134
+ Plugins are Ruby modules loaded from `~/.config/hiiro/plugins/`:
135
+
136
+ | Plugin | Description |
137
+ |--------|-------------|
138
+ | Pins | Per-command YAML key-value storage |
139
+ | Project | Project directory navigation with tmux session management |
140
+ | Task | Task lifecycle management across git worktrees with subtask support |
141
+ | Tmux | Tmux session helpers used by Project and Task |
142
+ | Notify | macOS desktop notifications via terminal-notifier |
143
+
72
144
  ## Adding Subcommands
73
145
 
74
146
  ### Method 1: External Executables
@@ -127,114 +199,6 @@ hiiro.add_subcommand(:pwd) do |*args, **values|
127
199
  end
128
200
  ```
129
201
 
130
- ## Abbreviations
131
-
132
- Any subcommand can be abbreviated as long as the prefix uniquely matches:
133
-
134
- ```sh
135
- h ex hel # matches h example hello
136
- h te # matches h test (if unique)
137
- h pp # matches h ppath
138
- ```
139
-
140
- If multiple commands match, the first match wins and a warning is logged (when logging is enabled).
141
-
142
- ## Built-in Plugins
143
-
144
- ### Pins
145
-
146
- Key-value storage persisted per command:
147
-
148
- ```sh
149
- h pin # List all pins
150
- h pin mykey # Get value
151
- h pin mykey myvalue # Set value
152
- h pin set mykey myvalue # Set value (explicit)
153
- h pin rm mykey # Remove pin
154
- ```
155
-
156
- Pins are stored in `~/.config/hiiro/pins/<command>.yml`.
157
-
158
- ### Tmux Subcommands
159
-
160
- Hiiro includes several tmux wrapper commands for managing sessions, windows, panes, and buffers. See [docs/](docs/) for full documentation.
161
-
162
- ```sh
163
- h session ls # List tmux sessions
164
- h window new # Create new window
165
- h pane split # Split current pane
166
- h buffer ls # List paste buffers
167
- ```
168
-
169
- ### Project
170
-
171
- Quick project navigation with tmux integration:
172
-
173
- ```sh
174
- h project myproj # cd to ~/proj/myproj and start tmux session
175
- ```
176
-
177
- Projects can be configured in `~/.config/hiiro/projects.yml`:
178
-
179
- ```yaml
180
- myproject: /path/to/project
181
- work: ~/work/main
182
- ```
183
-
184
- ### Task
185
-
186
- Manage development tasks across git worktrees in `~/work/`:
187
-
188
- ```sh
189
- h task list # Show trees and their active tasks
190
- h task start TICKET-123 # Start working on a task
191
- h task status # Show current task info
192
- h task app frontend # Open app directory in new tmux window
193
- h task save # Save current tmux window state
194
- h task stop # Release tree for other tasks
195
- ```
196
-
197
- Configure apps in `~/.config/hiiro/apps.yml`:
198
-
199
- ```yaml
200
- frontend: apps/frontend
201
- api: services/api
202
- admin: admin_portal/admin
203
- ```
204
-
205
- ### Notify (macOS)
206
-
207
- Desktop notifications via `terminal-notifier`:
208
-
209
- ```sh
210
- h notify "Build complete"
211
- h notify "Click me" "https://example.com"
212
- ```
213
-
214
- ### Video
215
-
216
- FFmpeg wrapper for common video operations:
217
-
218
- ```sh
219
- h video info movie.mp4 # Human-readable video summary
220
- h video resize720 movie.mp4 # Resize to 720p
221
- h video clip movie.mp4 00:01:30 60 # Extract 60s clip starting at 1:30
222
- h video gif movie.mp4 # Create animated GIF
223
- h video help # Full command list
224
- ```
225
-
226
- See [docs/h-video.md](docs/h-video.md) for complete documentation.
227
-
228
- ### Plugin
229
-
230
- Manage hiiro plugins:
231
-
232
- ```sh
233
- h plugin ls # List installed plugins
234
- h plugin edit pins # Edit a plugin file
235
- h plugin rg "some_method" # Search plugin code
236
- ```
237
-
238
202
  ## Writing Plugins
239
203
 
240
204
  Plugins are Ruby modules that extend Hiiro instances:
data/bin/h-subtask CHANGED
@@ -13,10 +13,20 @@ hiiro = Hiiro.init(*ARGV, plugins: [Tmux, Task])
13
13
  tasks = hiiro.task_manager
14
14
 
15
15
  hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
16
+ hiiro.add_subcmd(:list) { tasks.list_subtasks }
16
17
  hiiro.add_subcmd(:ls) { tasks.list_subtasks }
17
18
  hiiro.add_subcmd(:new) { |subtask_name| tasks.new_subtask(subtask_name) }
18
19
  hiiro.add_subcmd(:switch) { |subtask_name| tasks.switch_subtask(subtask_name) }
20
+ hiiro.add_subcmd(:app) { |app_name| tasks.subtask_open_app(app_name) }
21
+ hiiro.add_subcmd(:path) { |app_name=nil| tasks.subtask_app_path(app_name) }
22
+ hiiro.add_subcmd(:cd) { |*args| tasks.subtask_cd_app(*args) }
23
+ hiiro.add_subcmd(:apps) { tasks.list_configured_apps }
24
+ hiiro.add_subcmd(:status) { tasks.subtask_status }
25
+ hiiro.add_subcmd(:st) { tasks.subtask_status }
26
+ hiiro.add_subcmd(:save) { tasks.save_subtask }
27
+ hiiro.add_subcmd(:stop) { |subtask_name=nil| subtask_name ? tasks.stop_subtask(subtask_name) : tasks.stop_subtask }
28
+ hiiro.add_subcmd(:current) { print tasks.current_subtask_name }
19
29
 
20
- # hiiro.add_default { tasks.subtask_help }
30
+ hiiro.add_default { tasks.subtask_help }
21
31
 
22
32
  hiiro.run
data/bin/h-task CHANGED
@@ -90,13 +90,18 @@ class TaskManager
90
90
  # Find an available worktree to reuse, or create a new one
91
91
  available_tree = find_available_tree
92
92
 
93
+ # New tasks get a nested structure: task_name/main
94
+ # so subtasks can live alongside as task_name/subtask_name
95
+ subtree_name = "#{task_name}/main"
96
+
93
97
  if available_tree
94
- # Rename the available worktree to the task name
98
+ # Rename the available worktree to the task's main subtree
95
99
  old_path = tree_path(available_tree)
96
- new_path = File.join(File.dirname(old_path), task_name)
100
+ new_path = File.join(Dir.home, 'work', subtree_name)
97
101
 
98
- if available_tree != task_name
99
- puts "Renaming worktree '#{available_tree}' to '#{task_name}'..."
102
+ if available_tree != subtree_name
103
+ puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
104
+ FileUtils.mkdir_p(File.dirname(new_path))
100
105
  result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
101
106
  unless result
102
107
  puts "ERROR: Failed to rename worktree"
@@ -105,14 +110,15 @@ class TaskManager
105
110
  clear_worktree_cache
106
111
  end
107
112
 
108
- final_tree_name = task_name
113
+ final_tree_name = subtree_name
109
114
  final_tree_path = new_path
110
115
  else
111
116
  # No available worktree, create a new one
112
117
  puts "Creating new worktree for '#{task_name}'..."
113
- new_path = File.join(Dir.home, 'work', task_name)
118
+ new_path = File.join(Dir.home, 'work', subtree_name)
114
119
 
115
120
  # Create worktree from main branch (detached to avoid branch conflicts)
121
+ FileUtils.mkdir_p(File.dirname(new_path))
116
122
  result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
117
123
  unless result
118
124
  puts "ERROR: Failed to create worktree"
@@ -120,7 +126,7 @@ class TaskManager
120
126
  end
121
127
  clear_worktree_cache
122
128
 
123
- final_tree_name = task_name
129
+ final_tree_name = subtree_name
124
130
  final_tree_path = new_path
125
131
  end
126
132
 
@@ -343,11 +349,6 @@ class TaskManager
343
349
  tree = tree_for_task(task_name)
344
350
  task_name = task_for_tree(tree)
345
351
 
346
- if RESERVED_WORKTREES.key?(tree)
347
- puts "Cannot stop reserved task '#{task_name}'"
348
- return false
349
- end
350
-
351
352
  unassign_task_from_tree(tree)
352
353
  puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
353
354
  true
@@ -421,6 +422,9 @@ class TaskManager
421
422
  info = {}
422
423
  current_path = nil
423
424
 
425
+ work_dir = File.join(Dir.home, 'work')
426
+ work_prefix = work_dir + '/'
427
+
424
428
  output.lines.each do |line|
425
429
  line = line.strip
426
430
  if line.start_with?('worktree ')
@@ -431,7 +435,13 @@ class TaskManager
431
435
  elsif line.start_with?('branch ') || line == 'detached'
432
436
  # Capture worktree (both named branches and detached HEAD)
433
437
  if current_path && current_path != main_repo_path
434
- name = File.basename(current_path)
438
+ # Use relative path from ~/work/ to support nested worktrees
439
+ # e.g. ~/work/my-task/main -> "my-task/main"
440
+ name = if current_path.start_with?(work_prefix)
441
+ current_path.sub(work_prefix, '')
442
+ else
443
+ File.basename(current_path)
444
+ end
435
445
  info[name] = current_path
436
446
  end
437
447
  current_path = nil
@@ -455,12 +465,9 @@ class TaskManager
455
465
  worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
456
466
  end
457
467
 
458
- # Worktrees with permanent task assignments (worktree => task)
459
- RESERVED_WORKTREES = { 'carrot' => 'master' }.freeze
460
-
461
468
  # Find an available tree (one without an active task)
462
469
  def find_available_tree
463
- trees.find { |tree| task_for_tree(tree).nil? && !RESERVED_WORKTREES.key?(tree) }
470
+ trees.find { |tree| task_for_tree(tree).nil? }
464
471
  end
465
472
 
466
473
  # Get the task currently assigned to a tree
@@ -490,7 +497,6 @@ class TaskManager
490
497
 
491
498
  # Unassign task from tree
492
499
  def unassign_task_from_tree(tree_name)
493
- return if RESERVED_WORKTREES.key?(tree_name)
494
500
  data = assignments.dup
495
501
  data.delete(tree_name)
496
502
  save_assignments(data)
@@ -507,8 +513,7 @@ class TaskManager
507
513
  else
508
514
  {}
509
515
  end
510
- # Always include reserved worktree assignments
511
- RESERVED_WORKTREES.merge(data)
516
+ data
512
517
  end
513
518
 
514
519
  def save_assignments(data)
@@ -693,6 +698,103 @@ class TaskManager
693
698
  return [] unless $?.success?
694
699
  output.lines.map(&:strip)
695
700
  end
701
+
702
+ # Migrate flat worktrees (~/work/task) to nested (~/work/task/main)
703
+ def migrate_worktrees
704
+ # Find flat worktrees that need migration (not already nested, not reserved)
705
+ flat_trees = trees.reject { |name| name.include?('/') || RESERVED_WORKTREES.key?(name) }
706
+
707
+ if flat_trees.empty?
708
+ puts "No flat worktrees to migrate."
709
+ return
710
+ end
711
+
712
+ sessions = tmux_sessions
713
+
714
+ puts "Worktrees to migrate:"
715
+ puts
716
+ flat_trees.each do |name|
717
+ task = task_for_tree(name)
718
+ session_marker = sessions.include?(session_name_for(task || name)) ? " (tmux session running)" : ""
719
+ puts format(" %-20s => %s/main%s", name, name, session_marker)
720
+ end
721
+ puts
722
+
723
+ flat_trees.each do |name|
724
+ task = task_for_tree(name)
725
+ old_path = tree_path(name)
726
+ temp_name = "__migrating_#{name}"
727
+ temp_path = File.join(Dir.home, 'work', temp_name)
728
+ new_tree_name = "#{name}/main"
729
+ new_path = File.join(Dir.home, 'work', new_tree_name)
730
+
731
+ puts "Migrating '#{name}' -> '#{new_tree_name}'..."
732
+
733
+ # Step 1: Move worktree to temp location (frees up the directory name)
734
+ result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, temp_path)
735
+ unless result
736
+ puts " ERROR: Failed to move to temp location, skipping"
737
+ next
738
+ end
739
+
740
+ # Step 2: Create parent directory and move to final nested location
741
+ FileUtils.mkdir_p(File.join(Dir.home, 'work', name))
742
+ result = system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, new_path)
743
+ unless result
744
+ puts " ERROR: Failed to move to final location, rolling back..."
745
+ system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, old_path)
746
+ next
747
+ end
748
+
749
+ # Step 3: Update assignments (tree name changes, task name stays)
750
+ if task
751
+ data = load_assignments
752
+ data.delete(name)
753
+ data[new_tree_name] = task
754
+ save_assignments(RESERVED_WORKTREES.merge(data))
755
+ end
756
+
757
+ # Step 4: Update task metadata
758
+ if task
759
+ meta = task_metadata(task)
760
+ if meta
761
+ meta['tree'] = new_tree_name
762
+ FileUtils.mkdir_p(task_dir)
763
+ File.write(task_metadata_file(task), YAML.dump(meta))
764
+ end
765
+ end
766
+
767
+ # Step 5: Update tmux panes if a session is running
768
+ session_name = session_name_for(task || name)
769
+ if sessions.include?(session_name)
770
+ update_tmux_panes(session_name, old_path, new_path)
771
+ end
772
+
773
+ clear_worktree_cache
774
+ @assignments = nil
775
+ puts " Done."
776
+ end
777
+
778
+ puts
779
+ puts "Migration complete."
780
+ end
781
+
782
+ # Send `cd` to tmux panes whose cwd is under the old path
783
+ def update_tmux_panes(session_name, old_path, new_path)
784
+ output = `tmux list-panes -s -t '#{session_name}' -F '\#{pane_id}:\#{pane_current_path}' 2>/dev/null`
785
+ return unless $?.success?
786
+
787
+ output.lines.each do |line|
788
+ pane_id, pane_path = line.strip.split(':', 2)
789
+ next unless pane_path
790
+
791
+ if pane_path == old_path || pane_path.start_with?(old_path + '/')
792
+ updated_path = pane_path.sub(old_path, new_path)
793
+ system('tmux', 'send-keys', '-t', pane_id, "cd #{updated_path}", 'Enter')
794
+ puts " Updated pane #{pane_id}: cd #{updated_path}"
795
+ end
796
+ end
797
+ end
696
798
  end
697
799
 
698
800
  # Create task manager instance
@@ -713,5 +815,6 @@ hiiro.add_subcmd(:st) { tasks.status }
713
815
  hiiro.add_subcmd(:save) { tasks.save_current }
714
816
  hiiro.add_subcmd(:stop) { |task_name=nil| task_name ? tasks.stop_task(task_name) : tasks.stop_current }
715
817
  hiiro.add_subcmd(:current) { print tasks.task_name }
818
+ hiiro.add_subcmd(:migrate) { tasks.migrate_worktrees }
716
819
 
717
820
  hiiro.run
data/docs/README.md CHANGED
@@ -9,11 +9,30 @@ This directory contains detailed documentation for all Hiiro subcommands.
9
9
  | Command | Description |
10
10
  |---------|-------------|
11
11
  | [h-buffer](h-buffer.md) | Tmux paste buffer management |
12
+ | h-branch | Record and manage git branch history for tasks |
13
+ | h-dot | Compare directories and generate symlink/diff commands |
14
+ | h-dotfiles | Manage dotfiles in ~/proj/home |
15
+ | h-home | Manage home directory files with edit and search |
16
+ | h-html | Generate an HTML index of MP4 videos in current directory |
17
+ | h-link | Manage saved links with URL, description, and shorthand |
18
+ | h-mic | Control macOS microphone input volume |
19
+ | h-note | Create, edit, list, and display notes |
12
20
  | [h-pane](h-pane.md) | Tmux pane management |
13
- | [h-plugin](h-plugin.md) | Hiiro plugin management |
21
+ | [h-plugin](h-plugin.md) | Manage hiiro plugins (list, edit, search) |
22
+ | h-pr | Record PR information linked to tasks |
23
+ | h-pr-monitor | Monitor pull requests |
24
+ | h-pr-watch | Watch pull requests for updates |
25
+ | h-project | Open projects with tmux session management |
26
+ | h-runtask | Run templated task scripts |
27
+ | h-serve | Start a miniserve HTTP server on port 1111 |
14
28
  | [h-session](h-session.md) | Tmux session management |
15
- | [h-video](h-video.md) | FFmpeg wrapper for video operations |
29
+ | h-sha | Extract short SHA from git log |
30
+ | h-subtask | Shorthand for task subtask management |
31
+ | h-task | Comprehensive task manager for git worktrees |
32
+ | [h-video](h-video.md) | Video inspection and operations via ffprobe/ffmpeg |
33
+ | h-vim | Manage nvim configuration with edit and search |
16
34
  | [h-window](h-window.md) | Tmux window management |
35
+ | h-wtree | Git worktree management |
17
36
 
18
37
  ## Base Commands
19
38
 
@@ -21,14 +40,27 @@ The main `h` command includes these built-in subcommands:
21
40
 
22
41
  | Command | Description |
23
42
  |---------|-------------|
43
+ | `h version` | Display the Hiiro version |
44
+ | `h ping` | Simple test command (returns "pong") |
45
+ | `h setup` | Install plugins and subcommands to system paths |
24
46
  | `h edit` | Open the h script in your editor |
25
47
  | `h path` | Print the current directory |
26
48
  | `h ppath` | Print project path (git root + relative dir) |
27
49
  | `h rpath` | Print relative path from git root |
28
- | `h ping` | Simple test command (returns "pong") |
29
- | `h pin` | Key-value storage (via Pins plugin) |
30
- | `h project` | Project navigation (via Project plugin) |
31
- | `h task` | Task management (via Task plugin) |
50
+ | `h pin` | Per-command key-value storage (via Pins plugin) |
51
+ | `h project` | Project navigation with tmux integration (via Project plugin) |
52
+ | `h task` | Task management across git worktrees (via Task plugin) |
53
+ | `h notify` | macOS desktop notifications via terminal-notifier (via Notify plugin) |
54
+
55
+ ## Plugins
56
+
57
+ | Plugin | Description |
58
+ |--------|-------------|
59
+ | Pins | Per-command YAML key-value storage |
60
+ | Project | Project directory navigation with tmux session management |
61
+ | Task | Task lifecycle management across git worktrees with subtask support |
62
+ | Tmux | Tmux session helpers used by Project and Task |
63
+ | Notify | macOS desktop notifications via terminal-notifier |
32
64
 
33
65
  ## Abbreviations
34
66
 
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.27"
2
+ VERSION = "0.1.29"
3
3
  end
data/plugins/task.rb CHANGED
@@ -143,28 +143,29 @@ module Task
143
143
  return true
144
144
  end
145
145
 
146
- # If a specific tree was requested, verify it exists and isn't reserved
146
+ # If a specific tree was requested, verify it exists
147
147
  if tree
148
148
  if !trees.include?(tree)
149
149
  puts "ERROR: Worktree '#{tree}' not found"
150
150
  return false
151
151
  end
152
- if RESERVED_WORKTREES.key?(tree)
153
- puts "ERROR: Worktree '#{tree}' is reserved and cannot be used for tasks"
154
- return false
155
- end
156
152
  end
157
153
 
158
154
  # Find an available worktree to reuse, or create a new one
159
155
  available_tree = tree || find_available_tree
160
156
 
157
+ # New tasks get a nested structure: task_name/main
158
+ # so subtasks can live alongside as task_name/subtask_name
159
+ subtree_name = "#{task_name}/main"
160
+
161
161
  if available_tree
162
- # Rename the available worktree to the task name
162
+ # Rename the available worktree to the task's main subtree
163
163
  old_path = tree_path(available_tree)
164
- new_path = File.join(File.dirname(old_path), task_name)
164
+ new_path = File.join(Dir.home, 'work', subtree_name)
165
165
 
166
- if available_tree != task_name
167
- puts "Renaming worktree '#{available_tree}' to '#{task_name}'..."
166
+ if available_tree != subtree_name
167
+ puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
168
+ FileUtils.mkdir_p(File.dirname(new_path))
168
169
  result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
169
170
  unless result
170
171
  puts "ERROR: Failed to rename worktree"
@@ -173,14 +174,15 @@ module Task
173
174
  clear_worktree_cache
174
175
  end
175
176
 
176
- final_tree_name = task_name
177
+ final_tree_name = subtree_name
177
178
  final_tree_path = new_path
178
179
  else
179
180
  # No available worktree, create a new one
180
181
  puts "Creating new worktree for '#{task_name}'..."
181
- new_path = File.join(Dir.home, 'work', task_name)
182
+ new_path = File.join(Dir.home, 'work', subtree_name)
182
183
 
183
184
  # Create worktree from main branch (detached to avoid branch conflicts)
185
+ FileUtils.mkdir_p(File.dirname(new_path))
184
186
  result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
185
187
  unless result
186
188
  puts "ERROR: Failed to create worktree"
@@ -188,7 +190,7 @@ module Task
188
190
  end
189
191
  clear_worktree_cache
190
192
 
191
- final_tree_name = task_name
193
+ final_tree_name = subtree_name
192
194
  final_tree_path = new_path
193
195
  end
194
196
 
@@ -361,11 +363,6 @@ module Task
361
363
  tree = tree_for_task(task_name)
362
364
  task_name = task_for_tree(tree)
363
365
 
364
- if RESERVED_WORKTREES.key?(tree)
365
- puts "Cannot stop reserved task '#{task_name}'"
366
- return false
367
- end
368
-
369
366
  unassign_task_from_tree(tree)
370
367
  puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
371
368
  true
@@ -377,21 +374,49 @@ module Task
377
374
  puts " h subtask <subcommand> [args]"
378
375
  puts
379
376
  puts "Subcommands:"
380
- puts " ls List subtasks for current task"
377
+ puts " list, ls List subtasks for current task"
381
378
  puts " new SUBTASK_NAME Start a new subtask (creates worktree and session)"
382
379
  puts " switch SUBTASK_NAME Switch to subtask's tmux session"
380
+ puts " app APP_NAME Open a tmux window for an app in current subtask"
381
+ puts " apps List configured apps from apps.yml"
382
+ puts " cd [APP_NAME] Change directory to app in current subtask"
383
+ puts " path [APP_NAME] Print app path in current subtask"
384
+ puts " status, st Show current subtask status"
385
+ puts " save Save current subtask session info"
386
+ puts " stop [SUBTASK_NAME] Stop working on current/named subtask"
387
+ puts " current Print current subtask name"
383
388
  end
384
389
 
385
390
  def handle_subtask(*args)
386
391
  case args
387
392
  in []
388
393
  subtask_help
389
- in ['ls']
394
+ in ['list'] | ['ls']
390
395
  list_subtasks
391
396
  in ['new', subtask_name]
392
397
  new_subtask(subtask_name)
393
398
  in ['switch', subtask_name]
394
399
  switch_subtask(subtask_name)
400
+ in ['app', app_name]
401
+ subtask_open_app(app_name)
402
+ in ['apps']
403
+ list_configured_apps
404
+ in ['cd', *cd_args]
405
+ subtask_cd_app(*cd_args)
406
+ in ['path']
407
+ subtask_app_path
408
+ in ['path', app_name]
409
+ subtask_app_path(app_name)
410
+ in ['status'] | ['st']
411
+ subtask_status
412
+ in ['save']
413
+ save_subtask
414
+ in ['stop']
415
+ stop_subtask
416
+ in ['stop', subtask_name]
417
+ stop_subtask(subtask_name)
418
+ in ['current']
419
+ print current_subtask_name
395
420
  else
396
421
  puts "Unknown subtask command: #{args.inspect}"
397
422
  subtask_help
@@ -406,6 +431,10 @@ module Task
406
431
  end
407
432
 
408
433
  parent_task = current[:task]
434
+ # Handle if we're in a subtask - get the parent
435
+ if parent_task.include?('/')
436
+ parent_task = parent_task.split('/').first
437
+ end
409
438
  subtasks = subtasks_for_task(parent_task)
410
439
 
411
440
  if subtasks.empty?
@@ -437,7 +466,18 @@ module Task
437
466
  end
438
467
 
439
468
  parent_task = current[:task]
440
- full_subtask_name = "#{parent_task}/#{subtask_name}"
469
+
470
+ if parent_task.include?('/')
471
+ parent_task = parent_task.split('/').first
472
+ end
473
+
474
+ parent_dir =
475
+ if worktree_info.key?(parent_task)
476
+ "#{parent_task}_subtasks"
477
+ else
478
+ parent_task
479
+ end
480
+ full_subtask_name = "#{parent_dir}/#{subtask_name}"
441
481
 
442
482
  # Check if subtask already exists
443
483
  existing = subtasks_for_task(parent_task).find { |s| s['name'] == subtask_name }
@@ -525,6 +565,122 @@ module Task
525
565
  true
526
566
  end
527
567
 
568
+ # Show status for the current subtask
569
+ def subtask_status
570
+ current = current_task
571
+ unless current
572
+ puts "Not currently in a task session"
573
+ return
574
+ end
575
+
576
+ parent_task = parent_task_name(current[:task])
577
+ subtask_name = current_subtask_name_from(current[:task])
578
+
579
+ puts "Parent task: #{parent_task}"
580
+ if subtask_name
581
+ puts "Subtask: #{subtask_name}"
582
+ else
583
+ puts "Subtask: (main)"
584
+ end
585
+ puts "Worktree: #{current[:tree]}"
586
+ puts "Path: #{tree_path(current[:tree])}"
587
+ puts "Session: #{current[:session]}"
588
+ end
589
+
590
+ # Save current subtask tmux session state
591
+ def save_subtask
592
+ current = current_task
593
+ unless current
594
+ puts "ERROR: Not currently in a task session"
595
+ return false
596
+ end
597
+
598
+ parent_task = parent_task_name(current[:task])
599
+ session = current[:session]
600
+
601
+ windows = capture_tmux_windows(session)
602
+
603
+ save_task_metadata(parent_task,
604
+ tree: current[:tree],
605
+ session: session,
606
+ windows: windows,
607
+ saved_at: Time.now.iso8601
608
+ )
609
+
610
+ puts "Saved subtask session state (#{windows.count} windows)"
611
+ true
612
+ end
613
+
614
+ # Stop a subtask (remove from parent's subtask list, unassign tree)
615
+ def stop_subtask(subtask_name = nil)
616
+ current = current_task
617
+ unless current
618
+ puts "Not currently in a task session"
619
+ return false
620
+ end
621
+
622
+ parent_task = parent_task_name(current[:task])
623
+
624
+ if subtask_name.nil?
625
+ # Stop the current subtask
626
+ subtask_name = current_subtask_name_from(current[:task])
627
+ unless subtask_name
628
+ puts "Not currently in a subtask (in main task)"
629
+ return false
630
+ end
631
+ end
632
+
633
+ subtask = find_subtask(parent_task, subtask_name)
634
+ unless subtask
635
+ puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
636
+ return false
637
+ end
638
+
639
+ tree_name = subtask['worktree']
640
+ if tree_name
641
+ unassign_task_from_tree(tree_name)
642
+ end
643
+
644
+ # Mark subtask as inactive in parent metadata
645
+ meta = task_metadata(parent_task) || {}
646
+ if meta['subtasks']
647
+ meta['subtasks'].each do |s|
648
+ if s['name'].start_with?(subtask_name)
649
+ s['active'] = false
650
+ end
651
+ end
652
+ FileUtils.mkdir_p(task_dir)
653
+ File.write(task_metadata_file(parent_task), YAML.dump(meta))
654
+ end
655
+
656
+ puts "Stopped subtask '#{subtask_name}' (worktree available for reuse)"
657
+ true
658
+ end
659
+
660
+ # Print the current subtask name
661
+ def current_subtask_name
662
+ current = current_task
663
+ return nil unless current
664
+
665
+ subtask_name = current_subtask_name_from(current[:task])
666
+ subtask_name || current[:task]
667
+ end
668
+
669
+ # Open an app window scoped to the current subtask's tree
670
+ def subtask_open_app(app_name)
671
+ open_app(app_name)
672
+ end
673
+
674
+ # cd to app in current subtask's tree
675
+ def subtask_cd_app(*args)
676
+ cd_app(*args)
677
+ end
678
+
679
+ # Print app path in current subtask's tree
680
+ def subtask_app_path(app_name = nil, task: nil)
681
+ app_path(app_name, task: task || current_task&.dig(:task))
682
+ end
683
+
528
684
  private
529
685
 
530
686
  def subtasks_for_task(task_name)
@@ -546,6 +702,18 @@ module Task
546
702
  File.write(task_metadata_file(parent_task), YAML.dump(meta))
547
703
  end
548
704
 
705
+ # Extract parent task name from a potentially nested task/subtask name
706
+ def parent_task_name(task_name)
707
+ task_name.include?('/') ? task_name.split('/').first : task_name
708
+ end
709
+
710
+ # Extract subtask name from a nested task name (returns nil if main)
711
+ def current_subtask_name_from(task_name)
712
+ return nil unless task_name.include?('/')
713
+ parts = task_name.split('/', 2)
714
+ parts.last == 'main' ? nil : parts.last
715
+ end
716
+
549
717
  public
550
718
 
551
719
  def list_configured_apps
@@ -613,6 +781,9 @@ module Task
613
781
  info = {}
614
782
  current_path = nil
615
783
 
784
+ work_dir = File.join(Dir.home, 'work')
785
+ work_prefix = work_dir + '/'
786
+
616
787
  output.lines.each do |line|
617
788
  line = line.strip
618
789
  if line.start_with?('worktree ')
@@ -623,7 +794,13 @@ module Task
623
794
  elsif line.start_with?('branch ') || line == 'detached'
624
795
  # Capture worktree (both named branches and detached HEAD)
625
796
  if current_path && current_path != main_repo_path
626
- name = File.basename(current_path)
797
+ # Use relative path from ~/work/ to support nested worktrees
798
+ # e.g. ~/work/my-task/main -> "my-task/main"
799
+ name = if current_path.start_with?(work_prefix)
800
+ current_path.sub(work_prefix, '')
801
+ else
802
+ File.basename(current_path)
803
+ end
627
804
  info[name] = current_path
628
805
  end
629
806
  current_path = nil
@@ -647,12 +824,9 @@ module Task
647
824
  worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
648
825
  end
649
826
 
650
- # Worktrees with permanent task assignments (worktree => task)
651
- RESERVED_WORKTREES = { 'carrot' => 'master' }.freeze
652
-
653
827
  # Find an available tree (one without an active task)
654
828
  def find_available_tree
655
- trees.find { |tree| task_for_tree(tree).nil? && !RESERVED_WORKTREES.key?(tree) }
829
+ trees.find { |tree| task_for_tree(tree).nil? }
656
830
  end
657
831
 
658
832
  # Get the task currently assigned to a tree
@@ -682,7 +856,6 @@ module Task
682
856
 
683
857
  # Unassign task from tree
684
858
  def unassign_task_from_tree(tree_name)
685
- return if RESERVED_WORKTREES.key?(tree_name)
686
859
  data = assignments.dup
687
860
  data.delete(tree_name)
688
861
  save_assignments(data)
@@ -699,8 +872,7 @@ module Task
699
872
  else
700
873
  {}
701
874
  end
702
- # Always include reserved worktree assignments
703
- RESERVED_WORKTREES.merge(data)
875
+ data
704
876
  end
705
877
 
706
878
  def save_assignments(data)
data/script/compare CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/bin/bash
2
2
 
3
3
  diff_dirs() {
4
- diff -qrs ~/bin/ bin/ | egrep '\bbin(: |\/)h(\b|-)' | egrep -v 'identical'
4
+ diff -qrs bin/ ~/bin/ | egrep '\bbin(: |\/)h(\b|-)' | egrep -v 'identical'
5
5
 
6
- diff -qrs ~/.config/hiiro/plugins/ plugins/ | egrep -v 'identical'
6
+ diff -qrs plugins/ ~/.config/hiiro/plugins/ | egrep -v 'identical'
7
7
  }
8
8
 
9
9
  # s@Only in \(bin|plugins\): \(.*\)@cp -v \1/\2 $HOME/\1/@;
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.27
4
+ version: 0.1.29
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota