hiiro 0.1.104 → 0.1.105.pre.1

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: 91b42e1f81626b60d53e555716eb8dd2f5ed78430150d5f459f057c1866c1fc5
4
- data.tar.gz: 752c39ce5b291f32e23b5ad542f7b92659f65e3ace6290dbd014609113bca83e
3
+ metadata.gz: 02a9dfd205fe167b06ddca8e9166eef9fb0455e579e0f30aea8ebf2beebced8a
4
+ data.tar.gz: f6a2b07f167e9d9ce5e1e57b008b2860cb7888dbb56d4f8602a0150a3aae243c
5
5
  SHA512:
6
- metadata.gz: 6d33442eb08708cd8a77e29c8e8534925951703ee158f81109d0137004d25facaa5640d2381679885f1e9cc1a9d78efba927af1fae7e71f742826adfc342c029
7
- data.tar.gz: 6d8f6df9a92a723ebeee3c0bdb77b9d8260d538012e84aa391ac5aacf0b6ebe9f81770a159449bdaa39b20ebabe4326bee083e4eb6171d6c656ccc6e1cc0c992
6
+ metadata.gz: 8575faa796545313d705f3c850d94b922b6987b063ce8a645942a8ec94f19039719800f80cb360a1e2436cc4dc506f92f519eb73f7d70b1b9890593bf83f7270
7
+ data.tar.gz: 827a12ffa83d83c837d46daf38d0be7ec46f2b5f92d6d6ab5fea62e45e9b6f9d854347bf516ddef2e98f2414c3706158693e4939895f607d753ea39c9c1e174a
data/bin/h-app CHANGED
@@ -43,7 +43,7 @@ def send_cd(path)
43
43
  end
44
44
  end
45
45
 
46
- Hiiro.run(*ARGV, plugins: [:Tasks]) {
46
+ Hiiro.run(*ARGV) {
47
47
  add_subcmd(:config) {
48
48
  editor = ENV['EDITOR'] || 'nvim'
49
49
  system(editor, APPS_FILE)
data/bin/h-branch CHANGED
@@ -116,7 +116,7 @@ class BranchManager
116
116
  end
117
117
  end
118
118
 
119
- Hiiro.run(*ARGV, plugins: [Tasks]) do
119
+ Hiiro.run(*ARGV) do
120
120
  manager = BranchManager.new(self)
121
121
 
122
122
  add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
data/bin/h-pr CHANGED
@@ -416,7 +416,7 @@ class PinnedPRManager
416
416
  end
417
417
  end
418
418
 
419
- Hiiro.run(*ARGV, plugins: [Tasks, Tmux, Pins]) do
419
+ Hiiro.run(*ARGV, plugins: [Pins]) do
420
420
  manager = PRManager.new(self)
421
421
  pinned_manager = PinnedPRManager.new
422
422
 
data/bin/h-queue ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'hiiro'
4
+ require_relative '../lib/hiiro/queue'
5
+
6
+ q = Hiiro::Queue.current
7
+ Hiiro::Queue.build_hiiro(
8
+ Hiiro.new('h-queue', *ARGV),
9
+ q
10
+ ).run
data/bin/h-session CHANGED
@@ -4,7 +4,7 @@ require 'hiiro'
4
4
  require 'tempfile'
5
5
  require 'yaml'
6
6
 
7
- Hiiro.run(*ARGV, plugins: [Pins, Tasks]) do
7
+ Hiiro.run(*ARGV, plugins: [Pins]) do
8
8
  tmux = tmux_client
9
9
 
10
10
  add_subcmd(:ls, :list) { |*args|
data/hiiro.gemspec CHANGED
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_dependency "pry", "~> 0.14"
30
+ spec.add_dependency "front_matter_parser"
30
31
 
31
32
  spec.add_development_dependency "minitest", "~> 5.0"
32
33
  spec.add_development_dependency "rake", "~> 13.0"
@@ -0,0 +1,418 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+ require 'shellwords'
4
+ require 'time'
5
+ require 'front_matter_parser'
6
+
7
+ class Hiiro
8
+ class Queue
9
+ DIR = File.join(Dir.home, '.config/hiiro/queue')
10
+ TMUX_SESSION = 'hq'
11
+ STATUSES = %w[wip pending running done failed].freeze
12
+
13
+ def self.current(hiiro=nil)
14
+ @current ||= new(hiiro)
15
+ end
16
+
17
+ attr_reader :hiiro
18
+
19
+ def initialize(hiiro=nil)
20
+ @hiiro = hiiro
21
+ end
22
+
23
+ def read_prompt(filepath)
24
+ return false unless File.exist?(filepath)
25
+
26
+ Prompt.from_file(filepath)
27
+ end
28
+
29
+ def queue_dirs
30
+ @queue_dirs ||= STATUSES.each_with_object({}) do |name, h|
31
+ dir = File.join(DIR, name)
32
+ FileUtils.mkdir_p(dir)
33
+ h[name.to_sym] = dir
34
+ end
35
+ end
36
+
37
+ def tasks_in(status)
38
+ dir = queue_dirs[status]
39
+ Dir.glob(File.join(dir, '*.md')).sort.map { |f| File.basename(f, '.md') }
40
+ end
41
+
42
+ def all_tasks
43
+ STATUSES.flat_map do |status|
44
+ tasks_in(status.to_sym).map { |name| { name: name, status: status } }
45
+ end
46
+ end
47
+
48
+ def meta_for(name, status)
49
+ path = File.join(queue_dirs[status], "#{name}.meta")
50
+ File.exist?(path) ? YAML.safe_load_file(path) : nil
51
+ end
52
+
53
+ def find_task(name)
54
+ STATUSES.each do |status|
55
+ md = File.join(queue_dirs[status.to_sym], "#{name}.md")
56
+ return { name: name, status: status } if File.exist?(md)
57
+ end
58
+ nil
59
+ end
60
+
61
+ def ensure_tmux_session
62
+ unless system('tmux', 'has-session', '-t', TMUX_SESSION, out: File::NULL, err: File::NULL)
63
+ system('tmux', 'new-session', '-d', '-s', TMUX_SESSION)
64
+ end
65
+ end
66
+
67
+ def launch_task(name)
68
+ dirs = queue_dirs
69
+ md_file = File.join(dirs[:pending], "#{name}.md")
70
+ return unless File.exist?(md_file)
71
+
72
+ running_md = File.join(dirs[:running], "#{name}.md")
73
+ FileUtils.mv(md_file, running_md)
74
+
75
+ prompt_obj = Prompt.from_file(running_md, hiiro: hiiro)
76
+ prompt_text = File.read(running_md).strip
77
+
78
+ # Determine target tmux session and working directory from frontmatter
79
+ target_session = TMUX_SESSION
80
+ working_dir = Dir.pwd
81
+
82
+ if prompt_obj
83
+ if prompt_obj.task
84
+ target_session = prompt_obj.task.session_name
85
+ tree = prompt_obj.task.tree
86
+ working_dir = tree.path if tree
87
+ elsif prompt_obj.session
88
+ target_session = prompt_obj.session.name
89
+ end
90
+
91
+ if prompt_obj.tree
92
+ working_dir = prompt_obj.tree.path
93
+ end
94
+ end
95
+
96
+ # Ensure the target session exists
97
+ unless system('tmux', 'has-session', '-t', target_session, out: File::NULL, err: File::NULL)
98
+ system('tmux', 'new-session', '-d', '-s', target_session, '-c', working_dir)
99
+ end
100
+
101
+ # Build the cleanup script that runs after claude exits
102
+ cleanup_ruby = [
103
+ 'ruby', '-e',
104
+ 'require "fileutils"; ' \
105
+ "name=#{name.inspect}; qdir=#{DIR.inspect}; " \
106
+ 'exit_code = ENV["HQ_EXIT"].to_i; ' \
107
+ 'src=File.join(qdir,"running",name+".md"); ' \
108
+ 'dst_dir = exit_code == 0 ? "done" : "failed"; ' \
109
+ 'FileUtils.mv(src, File.join(qdir, dst_dir, name+".md")) if File.exist?(src); ' \
110
+ 'meta=File.join(qdir,"running",name+".meta"); ' \
111
+ 'FileUtils.mv(meta, File.join(qdir, dst_dir, name+".meta")) if File.exist?(meta)'
112
+ ].shelljoin
113
+
114
+ # Run claude interactively in tmux window, then cleanup on exit
115
+ shell_cmd = "cd #{Shellwords.shellescape(working_dir)} && claude #{Shellwords.shellescape(prompt_text)}; HQ_EXIT=$?; #{cleanup_ruby}; exec #{ENV['SHELL'] || 'zsh'}"
116
+
117
+ system('tmux', 'new-window', '-t', target_session, '-n', name, '-c', working_dir, shell_cmd)
118
+
119
+ # Write meta sidecar
120
+ meta = {
121
+ 'tmux_session' => target_session,
122
+ 'tmux_window' => name,
123
+ 'started_at' => Time.now.iso8601,
124
+ 'working_dir' => working_dir,
125
+ }
126
+ if prompt_obj
127
+ meta['task_name'] = prompt_obj.task_name if prompt_obj.task_name
128
+ meta['tree_name'] = prompt_obj.tree_name if prompt_obj.tree_name
129
+ meta['session_name'] = prompt_obj.session_name if prompt_obj.session_name
130
+ end
131
+ File.write(File.join(dirs[:running], "#{name}.meta"), meta.to_yaml)
132
+
133
+ puts "Launched: #{name} [#{target_session}]"
134
+ end
135
+
136
+ def slugify(text)
137
+ text.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')[0, 60]
138
+ end
139
+
140
+ def add_with_frontmatter(content, task_info: nil)
141
+ queue_dirs # ensure dirs exist
142
+
143
+ if task_info
144
+ fm = {}
145
+ fm['task_name'] = task_info[:task_name] if task_info[:task_name]
146
+ fm['tree_name'] = task_info[:tree_name] if task_info[:tree_name]
147
+ fm['session_name'] = task_info[:session_name] if task_info[:session_name]
148
+
149
+ if fm.any?
150
+ content = "---\n#{fm.map { |k, v| "#{k}: #{v}" }.join("\n")}\n---\n#{content}"
151
+ end
152
+ end
153
+
154
+ name = slugify(content.lines.reject { |l| l.start_with?('---') || l.match?(/^\w+:/) }.first.to_s.strip)
155
+ return nil if name.empty?
156
+
157
+ base_name = name
158
+ counter = 1
159
+ while File.exist?(File.join(DIR, 'pending', "#{name}.md"))
160
+ counter += 1
161
+ name = "#{base_name}-#{counter}"
162
+ end
163
+
164
+ path = File.join(DIR, 'pending', "#{name}.md")
165
+ File.write(path, content + "\n")
166
+ { name: name, path: path }
167
+ end
168
+
169
+ def self.build_hiiro(parent_hiiro, q=nil, task_info: nil)
170
+ q ||= current(parent_hiiro)
171
+
172
+ parent_hiiro.make_child do |h|
173
+ h.add_subcmd(:watch) {
174
+ q.queue_dirs
175
+ puts "Watching #{File.join(DIR, 'pending')} ..."
176
+ puts "Press Ctrl-C to stop"
177
+ loop do
178
+ q.tasks_in(:pending).each { |name| q.launch_task(name) }
179
+ sleep 2
180
+ end
181
+ }
182
+
183
+ h.add_subcmd(:run) { |name = nil|
184
+ if name
185
+ name = name.sub(/\.md$/, '')
186
+ found = q.find_task(name)
187
+ if found.nil?
188
+ puts "Task not found: #{name}"
189
+ next
190
+ end
191
+ if found[:status] != 'pending'
192
+ puts "Task '#{name}' is #{found[:status]}, not pending"
193
+ next
194
+ end
195
+ q.launch_task(name)
196
+ else
197
+ pending = q.tasks_in(:pending)
198
+ if pending.empty?
199
+ puts "No pending tasks"
200
+ next
201
+ end
202
+ pending.each { |n| q.launch_task(n) }
203
+ end
204
+ }
205
+
206
+ h.add_subcmd(:ls) {
207
+ tasks = q.all_tasks
208
+ if tasks.empty?
209
+ puts "No tasks"
210
+ next
211
+ end
212
+ tasks.each do |t|
213
+ puts "%-10s %s" % [t[:status], t[:name]]
214
+ end
215
+ }
216
+
217
+ h.add_subcmd(:list) {
218
+ tasks = q.all_tasks
219
+ if tasks.empty?
220
+ puts "No tasks"
221
+ next
222
+ end
223
+ tasks.each do |t|
224
+ puts "%-10s %s" % [t[:status], t[:name]]
225
+ end
226
+ }
227
+
228
+ h.add_subcmd(:status) {
229
+ tasks = q.all_tasks
230
+ if tasks.empty?
231
+ puts "No tasks"
232
+ next
233
+ end
234
+ tasks.each do |t|
235
+ meta = q.meta_for(t[:name], t[:status].to_sym)
236
+ line = "%-10s %s" % [t[:status], t[:name]]
237
+ if meta
238
+ started = meta['started_at']
239
+ if started && t[:status] == 'running'
240
+ elapsed = Time.now - Time.parse(started)
241
+ mins = (elapsed / 60).to_i
242
+ line += " (#{mins}m elapsed)"
243
+ end
244
+ line += " [#{meta['tmux_session']}:#{meta['tmux_window']}]" if meta['tmux_session']
245
+ line += " dir:#{meta['working_dir']}" if meta['working_dir']
246
+ end
247
+ puts line
248
+ end
249
+ }
250
+
251
+ h.add_subcmd(:attach) { |name = nil|
252
+ running = q.tasks_in(:running)
253
+ if running.empty?
254
+ puts "No running tasks"
255
+ next
256
+ end
257
+
258
+ if name.nil?
259
+ name = running.size == 1 ? running.first : h.fuzzyfind(running)
260
+ end
261
+
262
+ if name
263
+ meta = q.meta_for(name, :running)
264
+ session = meta&.[]('tmux_session') || TMUX_SESSION
265
+ exec('tmux', 'select-window', '-t', "#{session}:#{name}")
266
+ end
267
+ }
268
+
269
+ h.add_subcmd(:add) { |*args|
270
+ q.queue_dirs
271
+
272
+ if args.empty? && !$stdin.tty?
273
+ content = $stdin.read.strip
274
+ elsif args.any?
275
+ content = args.join(' ')
276
+ else
277
+ require 'tempfile'
278
+ tmpfile = Tempfile.new(['hq-', '.md'])
279
+
280
+ # Pre-fill with frontmatter template if task_info is available
281
+ if task_info
282
+ fm_lines = ["---"]
283
+ fm_lines << "task_name: #{task_info[:task_name]}" if task_info[:task_name]
284
+ fm_lines << "tree_name: #{task_info[:tree_name]}" if task_info[:tree_name]
285
+ fm_lines << "session_name: #{task_info[:session_name]}" if task_info[:session_name]
286
+ fm_lines << "---"
287
+ fm_lines << ""
288
+ tmpfile.write(fm_lines.join("\n"))
289
+ end
290
+
291
+ tmpfile.close
292
+ editor = ENV['EDITOR'] || 'vim'
293
+ system(editor, tmpfile.path)
294
+ content = File.read(tmpfile.path).strip
295
+ tmpfile.unlink
296
+ if content.empty?
297
+ puts "Aborted (empty file)"
298
+ next
299
+ end
300
+ end
301
+
302
+ result = q.add_with_frontmatter(content, task_info: task_info)
303
+ if result
304
+ puts "Created: #{result[:path]}"
305
+ else
306
+ puts "Could not generate a task name"
307
+ end
308
+ }
309
+
310
+ h.add_subcmd(:kill) { |name = nil|
311
+ running = q.tasks_in(:running)
312
+ if running.empty?
313
+ puts "No running tasks"
314
+ next
315
+ end
316
+
317
+ if name.nil?
318
+ name = running.size == 1 ? running.first : h.fuzzyfind(running)
319
+ end
320
+
321
+ next unless name
322
+
323
+ meta = q.meta_for(name, :running)
324
+ session = meta&.[]('tmux_session') || TMUX_SESSION
325
+ system('tmux', 'kill-window', '-t', "#{session}:#{name}")
326
+
327
+ dirs = q.queue_dirs
328
+ md = File.join(dirs[:running], "#{name}.md")
329
+ meta_path = File.join(dirs[:running], "#{name}.meta")
330
+ FileUtils.mv(md, File.join(dirs[:failed], "#{name}.md")) if File.exist?(md)
331
+ FileUtils.mv(meta_path, File.join(dirs[:failed], "#{name}.meta")) if File.exist?(meta_path)
332
+ puts "Killed: #{name}"
333
+ }
334
+
335
+ h.add_subcmd(:retry) { |name = nil|
336
+ retryable = q.tasks_in(:failed) + q.tasks_in(:done)
337
+ if retryable.empty?
338
+ puts "No failed/done tasks to retry"
339
+ next
340
+ end
341
+
342
+ if name.nil?
343
+ name = retryable.size == 1 ? retryable.first : h.fuzzyfind(retryable)
344
+ end
345
+
346
+ next unless name
347
+
348
+ found = q.find_task(name)
349
+ unless found && %w[failed done].include?(found[:status])
350
+ puts "Task '#{name}' is not in failed/done state"
351
+ next
352
+ end
353
+
354
+ dirs = q.queue_dirs
355
+ src_dir = dirs[found[:status].to_sym]
356
+ FileUtils.mv(File.join(src_dir, "#{name}.md"), File.join(dirs[:pending], "#{name}.md"))
357
+ meta_path = File.join(src_dir, "#{name}.meta")
358
+ FileUtils.rm_f(meta_path) if File.exist?(meta_path)
359
+ puts "Moved to pending: #{name}"
360
+ }
361
+
362
+ h.add_subcmd(:clean) {
363
+ dirs = q.queue_dirs
364
+ count = 0
365
+ %i[done failed].each do |status|
366
+ Dir.glob(File.join(dirs[status], '*')).each do |f|
367
+ FileUtils.rm_f(f)
368
+ count += 1
369
+ end
370
+ end
371
+ puts "Cleaned #{count} files"
372
+ }
373
+
374
+ h.add_subcmd(:dir) {
375
+ q.queue_dirs
376
+ puts DIR
377
+ }
378
+ end
379
+ end
380
+
381
+ class Prompt
382
+ def self.from_file(path, hiiro: nil)
383
+ return unless File.exist?(path)
384
+
385
+ new(FrontMatterParser::Parser.parse_file(path), hiiro:)
386
+ end
387
+
388
+ attr_reader :hiiro, :doc, :frontmatter, :prompt
389
+ attr_reader :task_name, :tree_name, :session_name
390
+
391
+ def initialize(doc, hiiro: nil)
392
+ @hiiro = hiiro
393
+ @doc = doc
394
+ @frontmatter = doc.front_matter
395
+ @prompt = prompt
396
+
397
+ @task_name = doc.front_matter['task_name']
398
+ @tree_name = doc.front_matter['tree_name']
399
+ @session_name = doc.front_matter['session_name']
400
+ end
401
+
402
+ def task
403
+ return nil unless task_name
404
+ hiiro&.environment&.find_task(task_name)
405
+ end
406
+
407
+ def session
408
+ return nil unless session_name
409
+ hiiro&.environment&.find_session(session_name)
410
+ end
411
+
412
+ def tree
413
+ return nil unless tree_name
414
+ hiiro&.environment&.find_tree(tree_name)
415
+ end
416
+ end
417
+ end
418
+ end