lux-hammer 0.3.21 → 0.3.23
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/.version +1 -1
- data/lib/hammer/shell.rb +33 -12
- data/recipes/lib/llm/plan.rb +660 -0
- data/recipes/lib/llm/wrap.rb +558 -87
- data/recipes/llm.rb +445 -17
- metadata +3 -2
data/recipes/llm.rb
CHANGED
|
@@ -7,7 +7,9 @@ desc <<~TXT
|
|
|
7
7
|
|
|
8
8
|
Namespaces:
|
|
9
9
|
memory persistent memory store (backs the Claude Code memory plugin)
|
|
10
|
+
plan apply a /plan bundle - sha1 checked, drift aware
|
|
10
11
|
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
12
|
+
todo per-project task queue for agents (./LLM_TODO.local.md)
|
|
11
13
|
|
|
12
14
|
Commands:
|
|
13
15
|
usage subscription limits and per-model token usage
|
|
@@ -23,6 +25,7 @@ require 'set'
|
|
|
23
25
|
# checkout still load matching lib/, not an older installed gem copy).
|
|
24
26
|
_llm_root = File.dirname(File.realpath(__FILE__))
|
|
25
27
|
require File.join(_llm_root, 'lib/llm/usage')
|
|
28
|
+
require File.join(_llm_root, 'lib/llm/plan')
|
|
26
29
|
|
|
27
30
|
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/ai/memory')
|
|
28
31
|
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
@@ -40,10 +43,10 @@ task :usage do
|
|
|
40
43
|
`~/.cache/llm/claude-limits.json`, falling back to the OAuth usage API. `month` shows
|
|
41
44
|
Claude extra-usage credits (API only, and only when you've enabled them).
|
|
42
45
|
D
|
|
43
|
-
example '
|
|
44
|
-
example '
|
|
45
|
-
example '
|
|
46
|
-
example '
|
|
46
|
+
example 'usage'
|
|
47
|
+
example 'usage month'
|
|
48
|
+
example 'usage --json'
|
|
49
|
+
example 'usage --provider grok'
|
|
47
50
|
|
|
48
51
|
# :period is declared first on purpose — the parser fills un-set scalar opts
|
|
49
52
|
# from positionals in declaration order, so `llm usage month` must reach
|
|
@@ -98,6 +101,17 @@ task :wrap do
|
|
|
98
101
|
inserts for you (history recall, autocomplete, menu picks) will not show up, and
|
|
99
102
|
nothing is captured while the program has echo off in canonical mode (sudo, ssh).
|
|
100
103
|
|
|
104
|
+
A line starting with ! is taken over: nothing reaches the wrapped program, the line
|
|
105
|
+
runs in $SHELL here, and the bar grows to show where it ran, what ran and what it
|
|
106
|
+
printed. That is a shell round trip instead of a tool call, a permission check and a
|
|
107
|
+
model turn - and it costs no context either, since the agent is never told.
|
|
108
|
+
|
|
109
|
+
Output stays up until you type anything at all, and a bare ! clears it too. Long
|
|
110
|
+
output is cut to the rows there are, and a command still running after 10s is killed,
|
|
111
|
+
since the wrapper is stopped while one runs. `cd` is handled here so that it sticks,
|
|
112
|
+
and moves nothing but the wrapper. Type !! to send a literal ! to the program instead,
|
|
113
|
+
which is how you reach Claude Code's own bash mode; a leading space does the same.
|
|
114
|
+
|
|
101
115
|
Keys are decoded from plain bytes, the kitty keyboard protocol and xterm
|
|
102
116
|
modifyOtherKeys, since a program can ask the terminal for any of the three (Claude
|
|
103
117
|
Code turns on the last two). Mouse reports, focus events, arrows and Ctrl-<key> are
|
|
@@ -108,27 +122,59 @@ task :wrap do
|
|
|
108
122
|
|
|
109
123
|
Put `--` before the wrapped command when it has flags of its own, otherwise they are
|
|
110
124
|
parsed as flags to `llm wrap`.
|
|
125
|
+
|
|
126
|
+
With no command at all, the commands in ~/.config/hammer/llm-wrap.txt are offered in
|
|
127
|
+
an arrow-key picker instead. That file is plain text, one command per line. A line
|
|
128
|
+
starting with # is dropped, and a line with no letter in it stays on screen but
|
|
129
|
+
cannot be picked - so blanks and ---- rules group the list without being in the way.
|
|
130
|
+
`--config` opens it in $EDITOR, writing a starting set the first time.
|
|
111
131
|
D
|
|
112
|
-
example '
|
|
113
|
-
example '
|
|
114
|
-
example '
|
|
132
|
+
example 'wrap'
|
|
133
|
+
example 'wrap --config'
|
|
134
|
+
example 'wrap claude'
|
|
135
|
+
example 'wrap -- claude --resume'
|
|
136
|
+
example 'wrap --lines 5 -- bash -l'
|
|
115
137
|
|
|
116
138
|
# `positional: false` is load-bearing: the parser fills un-set scalar opts
|
|
117
139
|
# from positionals in declaration order, so without it `llm wrap claude`
|
|
118
|
-
# would hand "claude" to --lines and die on the integer cast.
|
|
140
|
+
# would hand "claude" to --lines and die on the integer cast. `--config` is
|
|
141
|
+
# boolean, which the parser skips over, so it needs no such guard.
|
|
119
142
|
opt :lines, type: :integer, default: 3, positional: false,
|
|
120
143
|
desc: 'how many recent prompts to pin', placeholder: 'N'
|
|
144
|
+
opt :config, type: :boolean, desc: 'open the command list in $EDITOR'
|
|
121
145
|
|
|
122
146
|
proc do |opts|
|
|
123
|
-
cmd = Array(opts[:args])
|
|
124
|
-
error 'usage: llm wrap [--lines N] [--] <command> [args...]' if cmd.empty?
|
|
125
|
-
|
|
126
147
|
# Required lazily - llm.rb also runs as a per-prompt hook (`llm prompt
|
|
127
148
|
# hook`), which should not pay to load pty/io-console. `_llm_root` is a
|
|
128
149
|
# local from the file scope; the block closes over it (instance_exec
|
|
129
150
|
# rebinds self, not locals).
|
|
130
151
|
require File.join(_llm_root, 'lib/llm/wrap')
|
|
131
152
|
|
|
153
|
+
if opts[:config]
|
|
154
|
+
editor = ENV['EDITOR'] || ENV['VISUAL']
|
|
155
|
+
error '$EDITOR not set' unless editor
|
|
156
|
+
say.green "created #{LlmWrap::Config.path}" if LlmWrap::Config.seed
|
|
157
|
+
exit system(editor, LlmWrap::Config.path) ? 0 : 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
cmd = Array(opts[:args])
|
|
161
|
+
|
|
162
|
+
# Nothing asked for: offer the list rather than a usage error. The picker
|
|
163
|
+
# shows each line as it was written - re-joining a split argv would quote
|
|
164
|
+
# it back differently from what is in the file.
|
|
165
|
+
if cmd.empty?
|
|
166
|
+
LlmWrap::Config.seed
|
|
167
|
+
choices = LlmWrap::Config.lines
|
|
168
|
+
unless choices.any? { |line| LlmWrap::Config.runnable?(line) }
|
|
169
|
+
error "no commands in #{LlmWrap::Config.path} - add one with: llm wrap --config"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
idx = choose('wrap which command?', choices, skip: ->(line) { !LlmWrap::Config.runnable?(line) })
|
|
173
|
+
next say.gray('cancelled') unless idx
|
|
174
|
+
|
|
175
|
+
cmd = LlmWrap::Config.argv(choices[idx])
|
|
176
|
+
end
|
|
177
|
+
|
|
132
178
|
# The wrapper renames itself after the program it runs, so that a pane
|
|
133
179
|
# watcher can still see which agent is in there - which leaves nothing
|
|
134
180
|
# behind that says how the pane was started. This is that: the invocation
|
|
@@ -179,7 +225,7 @@ namespace :memory do
|
|
|
179
225
|
|
|
180
226
|
task :list do
|
|
181
227
|
desc 'List stored memories with type and one-line description'
|
|
182
|
-
example '
|
|
228
|
+
example 'memory list'
|
|
183
229
|
|
|
184
230
|
proc do
|
|
185
231
|
files = Dir[File.join(STORE, '*.md')].sort
|
|
@@ -199,7 +245,7 @@ namespace :memory do
|
|
|
199
245
|
|
|
200
246
|
task :read do
|
|
201
247
|
desc 'Print the full content of a memory (frontmatter + body)'
|
|
202
|
-
example '
|
|
248
|
+
example 'memory read user-role'
|
|
203
249
|
|
|
204
250
|
proc do |opts|
|
|
205
251
|
name = opts[:args].first
|
|
@@ -251,7 +297,7 @@ namespace :memory do
|
|
|
251
297
|
|
|
252
298
|
task :delete do
|
|
253
299
|
desc 'Delete a memory by name'
|
|
254
|
-
example '
|
|
300
|
+
example 'memory delete old-fact'
|
|
255
301
|
|
|
256
302
|
proc do |opts|
|
|
257
303
|
name = opts[:args].first
|
|
@@ -265,7 +311,7 @@ namespace :memory do
|
|
|
265
311
|
|
|
266
312
|
task :search do
|
|
267
313
|
desc 'Search memory bodies for a query string (case-insensitive)'
|
|
268
|
-
example '
|
|
314
|
+
example 'memory search react'
|
|
269
315
|
|
|
270
316
|
proc do |opts|
|
|
271
317
|
query = opts[:args].first
|
|
@@ -287,6 +333,287 @@ namespace :memory do
|
|
|
287
333
|
end
|
|
288
334
|
end
|
|
289
335
|
|
|
336
|
+
TODO_GUIDE ||= <<~TXT
|
|
337
|
+
Per-project task queue in ./LLM_TODO.local.md - workable by humans and any agent.
|
|
338
|
+
|
|
339
|
+
Human usage:
|
|
340
|
+
llm todo:add "fix login redirect" queue a task (pipe stdin for bulk / multi-line)
|
|
341
|
+
llm todo:list tasks by id, counts, format warnings
|
|
342
|
+
llm todo:pop + llm todo:done take / finish a single task by hand
|
|
343
|
+
|
|
344
|
+
Edit the file freely: only the `# todo`, `# doing` and `# done` sections are
|
|
345
|
+
managed. `# plan`, `# idea` and any prose stay untouched. A task starts with
|
|
346
|
+
`*` at the line start and runs until the next `*` or header; when finished it
|
|
347
|
+
moves to `# done` verbatim.
|
|
348
|
+
|
|
349
|
+
LLM usage - one of these lines is the whole prompt:
|
|
350
|
+
"Run `llm todo go` and follow its output." work through every task
|
|
351
|
+
"Run `llm todo pop`, do the task, run `llm todo done`." do a single task
|
|
352
|
+
|
|
353
|
+
Task text is printed raw on stdout. `todo go` repeats the protocol after each
|
|
354
|
+
task and says when the queue is empty; `todo pop` re-prints the task in
|
|
355
|
+
progress, so an interrupted agent resumes instead of skipping ahead.
|
|
356
|
+
TXT
|
|
357
|
+
|
|
358
|
+
task :todo do
|
|
359
|
+
desc <<~D
|
|
360
|
+
How the todo queue works, for humans and LLMs.
|
|
361
|
+
|
|
362
|
+
#{TODO_GUIDE}
|
|
363
|
+
D
|
|
364
|
+
example 'todo'
|
|
365
|
+
|
|
366
|
+
proc do
|
|
367
|
+
say TODO_GUIDE
|
|
368
|
+
say ''
|
|
369
|
+
self.class.print_help 'todo:'
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
namespace :todo do
|
|
374
|
+
# Same story as :memory - helpers must live inside the namespace block.
|
|
375
|
+
private
|
|
376
|
+
|
|
377
|
+
TODO_FILE ||= 'LLM_TODO.local.md'
|
|
378
|
+
TODO_SECTIONS ||= { todo: 'todo', doing: 'doing', done: 'done' }.freeze
|
|
379
|
+
|
|
380
|
+
def todo_path
|
|
381
|
+
File.join(Dir.pwd, TODO_FILE)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Exact title match on purpose - "# done ideas" must stay an unmanaged section.
|
|
385
|
+
def todo_section_key(title)
|
|
386
|
+
case title.strip.downcase
|
|
387
|
+
when 'todo', 'pending' then :todo
|
|
388
|
+
when 'doing', 'in progress', 'in-progress' then :doing
|
|
389
|
+
when 'done', 'completed' then :done
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# The tool manages only the `# todo`, `# doing` and `# done` sections (any
|
|
394
|
+
# heading level). Inside them a task starts at a `*` or `-` bullet in the
|
|
395
|
+
# first column and runs until the next bullet or header, so tasks can span
|
|
396
|
+
# multiple lines and move between sections verbatim. Every other section
|
|
397
|
+
# (`# plan`, `# idea`, ...) and any preamble is kept as-is and never touched.
|
|
398
|
+
# Returns [tasks, nodes]; nodes reproduce the document layout for todo_save.
|
|
399
|
+
def todo_parse
|
|
400
|
+
tasks = { todo: [], doing: [], done: [] }
|
|
401
|
+
nodes = []
|
|
402
|
+
return [tasks, nodes] unless File.file?(todo_path)
|
|
403
|
+
|
|
404
|
+
section = nil # managed section key, nil while inside a raw chunk
|
|
405
|
+
current = nil # lines of the task being collected
|
|
406
|
+
|
|
407
|
+
flush_task = lambda do
|
|
408
|
+
tasks[section] << current.join("\n").rstrip if current
|
|
409
|
+
current = nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
File.foreach(todo_path) do |raw|
|
|
413
|
+
line = raw.chomp
|
|
414
|
+
if (m = line.match(/^#+\s*(\S.*?)\s*$/))
|
|
415
|
+
flush_task.call
|
|
416
|
+
section = todo_section_key(m[1])
|
|
417
|
+
if section
|
|
418
|
+
nodes << [:section, section] unless nodes.include?([:section, section])
|
|
419
|
+
else
|
|
420
|
+
nodes << [:raw, [line]]
|
|
421
|
+
end
|
|
422
|
+
elsif section
|
|
423
|
+
if line =~ /^[-*]\s+(.*)$/
|
|
424
|
+
flush_task.call
|
|
425
|
+
current = [$1.strip]
|
|
426
|
+
elsif current
|
|
427
|
+
current << line.rstrip
|
|
428
|
+
end
|
|
429
|
+
else
|
|
430
|
+
nodes << [:raw, []] unless nodes.last && nodes.last[0] == :raw
|
|
431
|
+
nodes.last[1] << line.rstrip
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
flush_task.call
|
|
435
|
+
[tasks, nodes]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Rewrite: managed sections in canonical form, raw chunks verbatim, original
|
|
439
|
+
# order kept. Managed sections missing from the file are appended at the end.
|
|
440
|
+
def todo_save(tasks, nodes)
|
|
441
|
+
order = nodes + (TODO_SECTIONS.keys - nodes.map { |t, v| v if t == :section }).map { |k| [:section, k] }
|
|
442
|
+
|
|
443
|
+
File.open(todo_path, 'w') do |io|
|
|
444
|
+
order.each_with_index do |(type, value), i|
|
|
445
|
+
io.puts unless i.zero?
|
|
446
|
+
if type == :section
|
|
447
|
+
io.puts "# #{TODO_SECTIONS[value]}"
|
|
448
|
+
tasks[value].each do |text|
|
|
449
|
+
io.puts
|
|
450
|
+
io.puts "* #{text}"
|
|
451
|
+
end
|
|
452
|
+
else
|
|
453
|
+
lines = value.dup
|
|
454
|
+
lines.pop while lines.any? && lines.last.empty?
|
|
455
|
+
lines.each { |l| io.puts l }
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# One-line label for confirmations - multi-line tasks show their first line.
|
|
462
|
+
def todo_label(text)
|
|
463
|
+
first, rest = text.split("\n", 2)
|
|
464
|
+
rest ? "#{first} ..." : first
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Format sanity check for hand-edited files. Returns warning strings.
|
|
468
|
+
def todo_lint(tasks)
|
|
469
|
+
warns = []
|
|
470
|
+
seen = Hash.new(0)
|
|
471
|
+
section = :preamble
|
|
472
|
+
File.foreach(todo_path) do |raw|
|
|
473
|
+
line = raw.chomp
|
|
474
|
+
if (m = line.match(/^#+\s*(\S.*?)\s*$/))
|
|
475
|
+
key = todo_section_key(m[1])
|
|
476
|
+
section = key || :other
|
|
477
|
+
seen[key] += 1 if key
|
|
478
|
+
elsif section == :preamble && line =~ /^[-*]\s/
|
|
479
|
+
warns << "ignored bullet before any section header: #{line}"
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
TODO_SECTIONS.each_key do |key|
|
|
483
|
+
warns << "duplicate '# #{key}' sections - their tasks are merged" if seen[key] > 1
|
|
484
|
+
warns << "missing '# #{key}' section - will be added on next write" if seen[key].zero?
|
|
485
|
+
end
|
|
486
|
+
warns << "#{tasks[:doing].length} tasks in doing - expected at most 1" if tasks[:doing].length > 1
|
|
487
|
+
warns
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
task :add do
|
|
491
|
+
desc <<~DESC
|
|
492
|
+
Add task(s) to ./#{TODO_FILE} (created on first add).
|
|
493
|
+
|
|
494
|
+
Text comes from the argument (one task), or from stdin - there a `*` at
|
|
495
|
+
the start of a line begins a new task and following lines belong to it,
|
|
496
|
+
so multi-line tasks and whole lists can be piped in at once.
|
|
497
|
+
DESC
|
|
498
|
+
example 'todo add "migrate the user model to Sequel"'
|
|
499
|
+
example 'cat tasks.md | llm todo add'
|
|
500
|
+
opt :text, desc: 'task text (or pipe tasks on stdin)'
|
|
501
|
+
|
|
502
|
+
proc do |opts|
|
|
503
|
+
# unquoted multi-word input lands in :text + :args - join it back
|
|
504
|
+
arg = [opts[:text], *opts[:args]].compact.join(' ').strip
|
|
505
|
+
if arg.empty?
|
|
506
|
+
new_tasks = []
|
|
507
|
+
opts[:stdin].to_s.split("\n").each do |line|
|
|
508
|
+
if line =~ /^[-*]\s+(.*)$/
|
|
509
|
+
new_tasks << [$1.strip]
|
|
510
|
+
elsif new_tasks.any?
|
|
511
|
+
new_tasks.last << line.rstrip
|
|
512
|
+
elsif line.strip != ''
|
|
513
|
+
new_tasks << [line.rstrip]
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
new_tasks = new_tasks.map { |lines| lines.join("\n").rstrip }.reject(&:empty?)
|
|
517
|
+
else
|
|
518
|
+
new_tasks = [arg]
|
|
519
|
+
end
|
|
520
|
+
error 'usage: llm todo add <text> (or pipe tasks on stdin)' if new_tasks.empty?
|
|
521
|
+
|
|
522
|
+
tasks, nodes = todo_parse
|
|
523
|
+
tasks[:todo].concat new_tasks
|
|
524
|
+
todo_save tasks, nodes
|
|
525
|
+
new_tasks.each { |t| say "added: #{todo_label(t)}", :green }
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
task :pop do
|
|
530
|
+
desc <<~DESC
|
|
531
|
+
Print the current task and stop there - one task, no loop.
|
|
532
|
+
|
|
533
|
+
Moves the first todo task to doing if nothing is doing yet. Prints only
|
|
534
|
+
the raw task text (agent-friendly stdout). Re-running without `todo done`
|
|
535
|
+
prints the same task again, so an interrupted agent resumes instead of
|
|
536
|
+
starting the next one. Exits 1 when the list is empty.
|
|
537
|
+
DESC
|
|
538
|
+
example 'todo pop'
|
|
539
|
+
|
|
540
|
+
proc do
|
|
541
|
+
tasks, nodes = todo_parse
|
|
542
|
+
if tasks[:doing].empty?
|
|
543
|
+
error 'todo list is empty' if tasks[:todo].empty?
|
|
544
|
+
tasks[:doing] << tasks[:todo].shift
|
|
545
|
+
todo_save tasks, nodes
|
|
546
|
+
end
|
|
547
|
+
puts tasks[:doing].first
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
task :go do
|
|
552
|
+
desc <<~DESC
|
|
553
|
+
Agent entry point: work through the whole todo list.
|
|
554
|
+
|
|
555
|
+
Prints the current task (pop) followed by the loop protocol, so telling
|
|
556
|
+
any agent "run llm todo go" is enough to drain the queue. When the list
|
|
557
|
+
is empty it says so and tells the agent to stop and summarize.
|
|
558
|
+
DESC
|
|
559
|
+
example 'todo go'
|
|
560
|
+
|
|
561
|
+
proc do
|
|
562
|
+
tasks, nodes = todo_parse
|
|
563
|
+
if tasks[:doing].empty? && tasks[:todo].empty?
|
|
564
|
+
say 'All tasks done - stop the loop and summarize what was done.'
|
|
565
|
+
next
|
|
566
|
+
end
|
|
567
|
+
if tasks[:doing].empty?
|
|
568
|
+
tasks[:doing] << tasks[:todo].shift
|
|
569
|
+
todo_save tasks, nodes
|
|
570
|
+
end
|
|
571
|
+
say "TASK: #{tasks[:doing].first}"
|
|
572
|
+
say ''
|
|
573
|
+
say 'Do this task fully and verify it. Then run `llm todo done`, and `llm todo go` for the next one.'
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
task :done do
|
|
578
|
+
desc 'Mark the in-progress task as done'
|
|
579
|
+
example 'todo done'
|
|
580
|
+
|
|
581
|
+
proc do
|
|
582
|
+
tasks, nodes = todo_parse
|
|
583
|
+
error 'no task in progress (run: llm todo pop)' if tasks[:doing].empty?
|
|
584
|
+
text = tasks[:doing].shift
|
|
585
|
+
tasks[:done] << text
|
|
586
|
+
todo_save tasks, nodes
|
|
587
|
+
say "done: #{todo_label(text)}", :green
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
task :list do
|
|
592
|
+
alt :inspect
|
|
593
|
+
desc 'Inspect the todo file: tasks by id, counts, format validity warnings'
|
|
594
|
+
example 'todo list'
|
|
595
|
+
|
|
596
|
+
proc do
|
|
597
|
+
error "no #{TODO_FILE} in #{Dir.pwd} (run: llm todo add <text>)" unless File.file?(todo_path)
|
|
598
|
+
tasks, = todo_parse
|
|
599
|
+
id = 0
|
|
600
|
+
TODO_SECTIONS.each do |key, title|
|
|
601
|
+
say "#{title}:", :cyan
|
|
602
|
+
color = { todo: nil, doing: :yellow, done: :gray }[key]
|
|
603
|
+
tasks[key].each do |text|
|
|
604
|
+
first, *rest = text.split("\n")
|
|
605
|
+
say "#{(id += 1).to_s.rjust(3)}. #{first}", color
|
|
606
|
+
rest.each { |l| say " #{l}", color }
|
|
607
|
+
end
|
|
608
|
+
say ' (none)', :gray if tasks[key].empty?
|
|
609
|
+
end
|
|
610
|
+
say ''
|
|
611
|
+
say "#{tasks[:todo].length} todo, #{tasks[:doing].length} doing, #{tasks[:done].length} done"
|
|
612
|
+
todo_lint(tasks).each { |w| say "warning: #{w}", :yellow }
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
290
617
|
namespace :prompt do
|
|
291
618
|
TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
|
|
292
619
|
TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
|
|
@@ -524,8 +851,8 @@ namespace :prompt do
|
|
|
524
851
|
|
|
525
852
|
task :expand do
|
|
526
853
|
desc 'Expand prompt token(s) and print the resulting context'
|
|
527
|
-
example '
|
|
528
|
-
example '
|
|
854
|
+
example 'prompt:expand :foo :bar'
|
|
855
|
+
example 'prompt:expand foo:'
|
|
529
856
|
|
|
530
857
|
proc do |opts|
|
|
531
858
|
input = opts[:args].join(' ')
|
|
@@ -561,3 +888,104 @@ namespace :prompt do
|
|
|
561
888
|
end
|
|
562
889
|
end
|
|
563
890
|
end
|
|
891
|
+
|
|
892
|
+
# The namespace's sibling task: `llm plan` explains the thing, `llm plan:*`
|
|
893
|
+
# does it. The command list underneath is hammer's own, not a copy.
|
|
894
|
+
task :plan do
|
|
895
|
+
desc 'How the /plan bundle apply works: logic, bundle format, exit codes'
|
|
896
|
+
example 'plan'
|
|
897
|
+
|
|
898
|
+
proc do
|
|
899
|
+
say LlmPlan.readme
|
|
900
|
+
say ''
|
|
901
|
+
self.class.print_help 'plan:'
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
namespace :plan do
|
|
906
|
+
# Helpers live inside the namespace block for the same reason as :memory -
|
|
907
|
+
# a top-level `helpers do` lands on the root class, which namespaces do not
|
|
908
|
+
# inherit from.
|
|
909
|
+
private
|
|
910
|
+
|
|
911
|
+
def load_bundle(opts)
|
|
912
|
+
path = opts[:args].first
|
|
913
|
+
error 'usage: llm plan:<apply|check|verify|revert> ./tmp/plan-[SLUG].json' unless path
|
|
914
|
+
LlmPlan::Bundle.new(path)
|
|
915
|
+
rescue LlmPlan::Error => e
|
|
916
|
+
error e.message
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# Hammer owns colour (and turns it off for a non-tty), so hand its painter
|
|
920
|
+
# to the report rather than teaching the report about ANSI.
|
|
921
|
+
def render(outcome)
|
|
922
|
+
report = LlmPlan::Report.new(outcome, paint: Hammer::Shell.method(:paint))
|
|
923
|
+
report.lines.each { |text, color| say text, color }
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def run_verify(runner)
|
|
927
|
+
result = runner.verify { |cmd| say " > #{cmd}", :cyan }
|
|
928
|
+
result.ok ? say(' verify ok', :green) : say(" FAIL #{result.failed_command}", :red)
|
|
929
|
+
result
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
task :apply do
|
|
933
|
+
# Composed from plan.md rather than written out again - see `llm plan`.
|
|
934
|
+
desc ['Apply a /plan bundle: sha1-checked edits now, drift handed back to you.',
|
|
935
|
+
"The bundle, normally ./tmp/plan-[SLUG].json:\n\n#{LlmPlan.section('The bundle')}",
|
|
936
|
+
LlmPlan.section('Drift'),
|
|
937
|
+
LlmPlan.section('Exit codes')].join("\n\n")
|
|
938
|
+
example 'plan:apply ./tmp/plan-note-anchor.json'
|
|
939
|
+
|
|
940
|
+
proc do |opts|
|
|
941
|
+
bundle = load_bundle(opts)
|
|
942
|
+
runner = LlmPlan::Runner.new(bundle)
|
|
943
|
+
outcome = runner.apply
|
|
944
|
+
|
|
945
|
+
render outcome
|
|
946
|
+
outcome.verify = run_verify(runner) if outcome.clean?
|
|
947
|
+
|
|
948
|
+
exit outcome.exit_code
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
task :check do
|
|
953
|
+
desc ['Dry run: the summary to read before approving a plan. Writes nothing.',
|
|
954
|
+
LlmPlan.section('Summary')].join("\n\n")
|
|
955
|
+
example 'plan:check ./tmp/plan-note-anchor.json'
|
|
956
|
+
example 'plan:check --md ./tmp/plan-note-anchor.json'
|
|
957
|
+
|
|
958
|
+
opt :md, type: :boolean, desc: 'emit the summary as markdown, to paste into a reply'
|
|
959
|
+
|
|
960
|
+
proc do |opts|
|
|
961
|
+
outcome = LlmPlan::Runner.new(load_bundle(opts)).apply(check_only: true)
|
|
962
|
+
opts[:md] ? puts(LlmPlan::MarkdownReport.new(outcome).to_s) : render(outcome)
|
|
963
|
+
exit outcome.exit_code
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
task :verify do
|
|
968
|
+
desc <<~D
|
|
969
|
+
Run only the bundle's verify commands.
|
|
970
|
+
|
|
971
|
+
For after you have closed a drift by hand, or fixed what a failing verify
|
|
972
|
+
caught. Exit 0 green, 20 failed.
|
|
973
|
+
D
|
|
974
|
+
example 'plan:verify ./tmp/plan-note-anchor.json'
|
|
975
|
+
|
|
976
|
+
proc do |opts|
|
|
977
|
+
exit run_verify(LlmPlan::Runner.new(load_bundle(opts))).ok ? 0 : 20
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
task :revert do
|
|
982
|
+
desc 'Undo an applied bundle from <slug>.bak: restore changed and deleted files, remove created ones.'
|
|
983
|
+
example 'plan:revert ./tmp/plan-note-anchor.json'
|
|
984
|
+
|
|
985
|
+
proc do |opts|
|
|
986
|
+
LlmPlan::Runner.new(load_bundle(opts)).revert.each { |path| say " restored #{path}", :green }
|
|
987
|
+
rescue LlmPlan::Error => e
|
|
988
|
+
error e.message
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.23
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- "./lib/lux-hammer.rb"
|
|
56
56
|
- "./recipes/deploy.rb"
|
|
57
57
|
- "./recipes/git-helper.rb"
|
|
58
|
+
- "./recipes/lib/llm/plan.rb"
|
|
58
59
|
- "./recipes/lib/llm/usage.rb"
|
|
59
60
|
- "./recipes/lib/llm/wrap.rb"
|
|
60
61
|
- "./recipes/llm.rb"
|