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 +4 -4
- data/bin/h-app +1 -1
- data/bin/h-branch +1 -1
- data/bin/h-pr +1 -1
- data/bin/h-queue +10 -0
- data/bin/h-session +1 -1
- data/hiiro.gemspec +1 -0
- data/lib/hiiro/queue.rb +418 -0
- data/lib/hiiro/tasks.rb +1020 -0
- data/lib/hiiro/version.rb +1 -1
- data/lib/hiiro.rb +55 -9
- data/plugins/tasks.rb +14 -17
- metadata +20 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02a9dfd205fe167b06ddca8e9166eef9fb0455e579e0f30aea8ebf2beebced8a
|
|
4
|
+
data.tar.gz: f6a2b07f167e9d9ce5e1e57b008b2860cb7888dbb56d4f8602a0150a3aae243c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8575faa796545313d705f3c850d94b922b6987b063ce8a645942a8ec94f19039719800f80cb360a1e2436cc4dc506f92f519eb73f7d70b1b9890593bf83f7270
|
|
7
|
+
data.tar.gz: 827a12ffa83d83c837d46daf38d0be7ec46f2b5f92d6d6ab5fea62e45e9b6f9d854347bf516ddef2e98f2414c3706158693e4939895f607d753ea39c9c1e174a
|
data/bin/h-app
CHANGED
data/bin/h-branch
CHANGED
data/bin/h-pr
CHANGED
data/bin/h-queue
ADDED
data/bin/h-session
CHANGED
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"
|
data/lib/hiiro/queue.rb
ADDED
|
@@ -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
|