lux-hammer 0.3.21 → 0.3.22
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 +651 -0
- data/recipes/lib/llm/wrap.rb +61 -0
- data/recipes/llm.rb +152 -17
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df3eb16fdf398c03d043585e48e4f9792d09b4427f0a0733eefa9725f1f97b0d
|
|
4
|
+
data.tar.gz: d36961cbeb0e10d44fa5119629a436aeed2d16fa384fbc0256dfb9cf1f9893ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8429a1a3a276bca73309522c57c78f3283a78ed327cb0d4354757c3bccd06bf5a0202af888f5ebfa8e66c01850cb4cf927317b8c68970a18b3a9370ff5a70516
|
|
7
|
+
data.tar.gz: 68ffb3baf274099eae5e3c3398f77bbda5163c3867980772c737e06d211a8ce26de08702f51e53b8d27c067703eedaac482f5e4f7c2b133e638272ef10b319c6
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.22
|
data/lib/hammer/shell.rb
CHANGED
|
@@ -88,26 +88,43 @@ class Hammer
|
|
|
88
88
|
#
|
|
89
89
|
# idx = choose 'Pick env', %w[dev staging prod]
|
|
90
90
|
# say.green "chose #{ %w[dev staging prod][idx] }" if idx
|
|
91
|
-
|
|
91
|
+
#
|
|
92
|
+
# `skip` marks rows that are shown but never landed on, so a list can
|
|
93
|
+
# carry its own headings and rules. They are drawn in gray, the cursor
|
|
94
|
+
# steps over them, and the returned index still counts every row:
|
|
95
|
+
#
|
|
96
|
+
# choose 'Pick', ['-- fast --', 'dev', '-- slow --', 'prod'],
|
|
97
|
+
# skip: ->(item) { item.start_with?('--') }
|
|
98
|
+
def choose(prompt, items, skip: nil)
|
|
92
99
|
items = items.to_a
|
|
93
100
|
error 'choose needs at least one item' if items.empty?
|
|
94
101
|
|
|
102
|
+
live = items.each_index.reject { |i| skip&.call(items[i]) }
|
|
103
|
+
error 'choose needs at least one selectable item' if live.empty?
|
|
104
|
+
|
|
95
105
|
say.cyan prompt
|
|
96
106
|
|
|
97
|
-
return choose_numbered(items) unless $stdin.tty? && $stdin.respond_to?(:raw)
|
|
107
|
+
return choose_numbered(items, live) unless $stdin.tty? && $stdin.respond_to?(:raw)
|
|
98
108
|
|
|
99
|
-
selected =
|
|
109
|
+
selected = live.first
|
|
100
110
|
# In raw mode \n is not translated to \r\n, so the picker uses \r\n
|
|
101
111
|
# explicitly. The initial draw happens in cooked mode but \r\n is
|
|
102
112
|
# harmless there.
|
|
103
113
|
redraw = lambda do |highlight = :cyan|
|
|
104
114
|
items.each_with_index do |item, i|
|
|
105
|
-
line = i
|
|
115
|
+
line = if !live.include?(i) then paint(" #{item}", :gray)
|
|
116
|
+
elsif i == selected then paint("> #{item}", highlight)
|
|
117
|
+
else " #{item}"
|
|
118
|
+
end
|
|
106
119
|
$stdout.print "#{line}\r\n"
|
|
107
120
|
end
|
|
108
121
|
end
|
|
109
122
|
redraw.call
|
|
110
123
|
|
|
124
|
+
# Move by position among the selectable rows, so skipped ones are
|
|
125
|
+
# stepped over in both directions and the wrap-around still works.
|
|
126
|
+
step = ->(dir) { live[(live.index(selected) + dir) % live.size] }
|
|
127
|
+
|
|
111
128
|
$stdout.print "\e[?25l" # hide cursor
|
|
112
129
|
begin
|
|
113
130
|
$stdin.raw do |io|
|
|
@@ -126,15 +143,15 @@ class Hammer
|
|
|
126
143
|
# ESC may stand alone or start an arrow sequence \e[A / \e[B.
|
|
127
144
|
if IO.select([io], nil, nil, 0.01) && io.getch == '['
|
|
128
145
|
case io.getch
|
|
129
|
-
when 'A' then selected = (
|
|
130
|
-
when 'B' then selected = (
|
|
146
|
+
when 'A' then selected = step.call(-1)
|
|
147
|
+
when 'B' then selected = step.call(1)
|
|
131
148
|
end
|
|
132
149
|
else
|
|
133
150
|
$stdout.print "\e[#{items.size}A\r\e[J"
|
|
134
151
|
return nil
|
|
135
152
|
end
|
|
136
|
-
when 'k' then selected = (
|
|
137
|
-
when 'j' then selected = (
|
|
153
|
+
when 'k' then selected = step.call(-1)
|
|
154
|
+
when 'j' then selected = step.call(1)
|
|
138
155
|
end
|
|
139
156
|
$stdout.print "\e[#{items.size}A\r\e[J"
|
|
140
157
|
redraw.call
|
|
@@ -146,13 +163,17 @@ class Hammer
|
|
|
146
163
|
end
|
|
147
164
|
|
|
148
165
|
# Fallback for non-TTY stdin (pipes, tests). Returns the index or nil.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
# Skipped rows are still printed, just without a number to type.
|
|
167
|
+
def choose_numbered(items, live = items.each_index.to_a)
|
|
168
|
+
items.each_with_index do |item, i|
|
|
169
|
+
n = live.index(i)
|
|
170
|
+
puts n ? " #{n + 1}) #{item}" : " #{item}"
|
|
171
|
+
end
|
|
172
|
+
print paint("select [1-#{live.size}]: ", :cyan)
|
|
152
173
|
line = $stdin.gets
|
|
153
174
|
return nil if line.nil?
|
|
154
175
|
idx = line.strip.to_i - 1
|
|
155
|
-
idx.between?(0,
|
|
176
|
+
idx.between?(0, live.size - 1) ? live[idx] : nil
|
|
156
177
|
end
|
|
157
178
|
|
|
158
179
|
# Run a shell command. Echoes the command in gray, raises
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
# Applies a /plan bundle - a JSON file that pairs every intended edit with the
|
|
8
|
+
# sha1 its target had when the plan was written.
|
|
9
|
+
#
|
|
10
|
+
# The point is a fast apply after slow planning: a file whose sha1 still
|
|
11
|
+
# matches is written without anyone re-reading it, and a file that drifted is
|
|
12
|
+
# handed back to the caller with the intent and the wanted text, to be applied
|
|
13
|
+
# by judgement instead of by string match. Nothing here calls an LLM; it tells
|
|
14
|
+
# the one that is already running what is left to do.
|
|
15
|
+
#
|
|
16
|
+
# Safety rules that hold regardless of the bundle:
|
|
17
|
+
# - a file is only written once every hunk in it resolves, so no file is
|
|
18
|
+
# ever left half-edited
|
|
19
|
+
# - writes go through a temp file and a rename, keeping the original mode
|
|
20
|
+
# - every touched file is copied under <slug>.bak before it changes, and the
|
|
21
|
+
# undo log is flushed per file so a crash mid-run is still revertible
|
|
22
|
+
# - paths must stay inside the working tree and may not be symlinks
|
|
23
|
+
# - a bundle that already landed re-runs as a no-op, not as drift
|
|
24
|
+
module LlmPlan
|
|
25
|
+
Error = Class.new(StandardError)
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# plan.md is the only copy of the prose: `llm plan` prints it whole, and
|
|
29
|
+
# the per-command help is composed from its sections.
|
|
30
|
+
def readme
|
|
31
|
+
@readme ||= File.read(File.join(__dir__, 'plan.md'))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The body of one `## Section`, without its heading. Trims blank lines
|
|
35
|
+
# only - stripping whitespace would eat the first line's indentation and
|
|
36
|
+
# break the code blocks this composes into command help.
|
|
37
|
+
def section(title)
|
|
38
|
+
body = readme[/^## #{Regexp.escape(title)}\s*\n(.*?)(?=^## |\z)/m, 1]
|
|
39
|
+
raise Error, "plan.md has no '#{title}' section" unless body
|
|
40
|
+
body.sub(/\A\n+/, '').rstrip
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One planned file operation, plus everything needed to decide whether the
|
|
45
|
+
# world still looks the way the plan assumed.
|
|
46
|
+
class Entry
|
|
47
|
+
OPS = %w[create change delete].freeze
|
|
48
|
+
PAST = { 'create' => 'created', 'change' => 'changed', 'delete' => 'deleted' }.freeze
|
|
49
|
+
ANCHOR_WIDTH = 60
|
|
50
|
+
|
|
51
|
+
attr_reader :path, :op, :sha1
|
|
52
|
+
|
|
53
|
+
def initialize(raw, root:)
|
|
54
|
+
@raw = raw
|
|
55
|
+
@root = root
|
|
56
|
+
@path = raw['path'].to_s
|
|
57
|
+
@op = raw['op'].to_s
|
|
58
|
+
@sha1 = raw['sha1'].to_s
|
|
59
|
+
validate!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def hunks = Array(@raw['hunks'])
|
|
63
|
+
def content = @raw['content'].to_s
|
|
64
|
+
def past = PAST[op]
|
|
65
|
+
|
|
66
|
+
# The one-clause summary for the manifest. Falls back to the hunk intents,
|
|
67
|
+
# so a change usually needs no `note` of its own.
|
|
68
|
+
def note
|
|
69
|
+
given = @raw['note'].to_s.strip
|
|
70
|
+
return given unless given.empty?
|
|
71
|
+
|
|
72
|
+
hunks.filter_map { |hunk| hunk['intent'].to_s.strip if hunk['intent'] }.reject(&:empty?).join('; ')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# sha1 of the file as it is right now, or nil when it is not there.
|
|
76
|
+
def current_sha1
|
|
77
|
+
File.file?(abs) ? Digest::SHA1.file(abs).hexdigest : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# nil when the file is byte for byte what the plan assumed, otherwise a
|
|
81
|
+
# sentence the caller can act on.
|
|
82
|
+
def drift_reason
|
|
83
|
+
return "#{path} is a symlink, refusing to write through it" if File.symlink?(abs)
|
|
84
|
+
|
|
85
|
+
if op == 'create'
|
|
86
|
+
return nil unless File.exist?(abs)
|
|
87
|
+
'planned as a new file, but it exists now'
|
|
88
|
+
else
|
|
89
|
+
return 'planned file is gone' unless File.file?(abs)
|
|
90
|
+
got = current_sha1
|
|
91
|
+
return nil if got == sha1
|
|
92
|
+
"changed since planning (sha1 #{got[0, 10]}, planned #{sha1[0, 10]})"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A second run of a bundle that already landed is a no-op, not drift.
|
|
97
|
+
# `record` is this entry's line from the undo log, or nil.
|
|
98
|
+
def already_applied?(record)
|
|
99
|
+
return false unless record
|
|
100
|
+
return !File.exist?(abs) if op == 'delete'
|
|
101
|
+
current_sha1 == record['after']
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Writes the entry. Returns nil on success, or a reason string naming what
|
|
105
|
+
# the caller has to finish by hand.
|
|
106
|
+
def apply!(backup)
|
|
107
|
+
case op
|
|
108
|
+
when 'create'
|
|
109
|
+
FileUtils.mkdir_p File.dirname(abs)
|
|
110
|
+
write content
|
|
111
|
+
when 'delete'
|
|
112
|
+
backup.save abs, path
|
|
113
|
+
FileUtils.rm_f abs
|
|
114
|
+
when 'change'
|
|
115
|
+
body = File.read(abs)
|
|
116
|
+
|
|
117
|
+
# Resolve every hunk against the in-memory copy first - a bundle with
|
|
118
|
+
# one bad anchor leaves the file exactly as it was.
|
|
119
|
+
hunks.each do |hunk|
|
|
120
|
+
body, reason = splice(body, hunk)
|
|
121
|
+
return reason if reason
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
backup.save abs, path
|
|
125
|
+
write body
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The file as the hunks would leave it: [body, nil], or [nil, reason] when
|
|
132
|
+
# an anchor will not resolve. Only meaningful for a change.
|
|
133
|
+
def preview
|
|
134
|
+
@preview ||= resolve_preview
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Resolves the hunks and throws the result away, so a bad anchor - which is
|
|
138
|
+
# a planning bug, not drift - surfaces while the plan can still be fixed.
|
|
139
|
+
def dry_apply
|
|
140
|
+
return nil unless op == 'change'
|
|
141
|
+
|
|
142
|
+
preview.last
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# [added, removed] line counts. Multiset-wise: a line that only moved
|
|
146
|
+
# counts as neither, which reads better in a summary than a positional
|
|
147
|
+
# diff would. Zero for anything that cannot be resolved.
|
|
148
|
+
def delta
|
|
149
|
+
case op
|
|
150
|
+
when 'create' then [content.lines.size, 0]
|
|
151
|
+
when 'delete' then [0, File.file?(abs) ? File.read(abs).lines.size : 0]
|
|
152
|
+
else
|
|
153
|
+
body, = preview
|
|
154
|
+
body ? line_delta(File.read(abs), body) : [0, 0]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def abs = File.expand_path(path, @root)
|
|
161
|
+
|
|
162
|
+
def resolve_preview
|
|
163
|
+
body = File.read(abs)
|
|
164
|
+
|
|
165
|
+
hunks.each do |hunk|
|
|
166
|
+
body, reason = splice(body, hunk)
|
|
167
|
+
return [nil, reason] if reason
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
[body, nil]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def line_delta(old_body, new_body)
|
|
174
|
+
before = old_body.lines.tally
|
|
175
|
+
after = new_body.lines.tally
|
|
176
|
+
|
|
177
|
+
[after.sum { |line, n| [n - before.fetch(line, 0), 0].max },
|
|
178
|
+
before.sum { |line, n| [n - after.fetch(line, 0), 0].max }]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate!
|
|
182
|
+
raise Error, 'file entry without a path' if path.empty?
|
|
183
|
+
raise Error, "#{path}: unknown op #{op.inspect}" unless OPS.include?(op)
|
|
184
|
+
raise Error, "#{path}: absolute paths are not allowed" if path.start_with?('/')
|
|
185
|
+
raise Error, "#{path}: path escapes the working tree" unless inside_root?
|
|
186
|
+
raise Error, "#{path}: create needs content" if op == 'create' && !@raw.key?('content')
|
|
187
|
+
raise Error, "#{path}: #{op} needs a sha1" if op != 'create' && sha1.empty?
|
|
188
|
+
raise Error, "#{path}: change needs at least one hunk" if op == 'change' && hunks.empty?
|
|
189
|
+
|
|
190
|
+
hunks.each do |hunk|
|
|
191
|
+
raise Error, "#{path}: hunk needs both old and new" unless hunk['old'] && hunk['new']
|
|
192
|
+
raise Error, "#{path}: hunk old and new are identical" if hunk['old'] == hunk['new']
|
|
193
|
+
raise Error, "#{path}: hunk old is empty" if hunk['old'].to_s.empty?
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def inside_root?
|
|
198
|
+
File.expand_path(path, @root).start_with?("#{@root}/")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Block form of sub/gsub on purpose: a replacement passed as a string would
|
|
202
|
+
# read \1 and \& in the new text as backreferences.
|
|
203
|
+
def splice(body, hunk)
|
|
204
|
+
old, new = hunk['old'].to_s, hunk['new'].to_s
|
|
205
|
+
found = body.scan(old).size
|
|
206
|
+
|
|
207
|
+
if hunk['all']
|
|
208
|
+
return [body, "anchor never matched: #{anchor(old)}"] if found.zero?
|
|
209
|
+
[body.gsub(old) { new }, nil]
|
|
210
|
+
else
|
|
211
|
+
return [body, "anchor matched #{found} times, needs exactly 1: #{anchor(old)}"] unless found == 1
|
|
212
|
+
[body.sub(old) { new }, nil]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def anchor(text)
|
|
217
|
+
line = text.lines.first.to_s.strip
|
|
218
|
+
line.length > ANCHOR_WIDTH ? "#{line[0, ANCHOR_WIDTH - 3]}..." : line
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def write(body)
|
|
222
|
+
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
223
|
+
|
|
224
|
+
mode = File.exist?(abs) ? File.stat(abs).mode : nil
|
|
225
|
+
tmp = "#{abs}.llm-plan.#{Process.pid}"
|
|
226
|
+
|
|
227
|
+
File.write tmp, body
|
|
228
|
+
File.chmod mode, tmp if mode
|
|
229
|
+
File.rename tmp, abs
|
|
230
|
+
ensure
|
|
231
|
+
FileUtils.rm_f tmp if tmp && File.exist?(tmp)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Copies of everything a run touched, plus the log that says how to undo it.
|
|
236
|
+
class Backup
|
|
237
|
+
LOG = '_applied.json'
|
|
238
|
+
|
|
239
|
+
def initialize(dir, root:)
|
|
240
|
+
@dir = dir
|
|
241
|
+
@root = root
|
|
242
|
+
@log = read_log
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def record_for(entry)
|
|
246
|
+
@log.find { |row| row['path'] == entry.path }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def save(abs, path)
|
|
250
|
+
return unless File.exist?(abs)
|
|
251
|
+
|
|
252
|
+
dest = File.join(@dir, path.sub(%r{\A\./}, ''))
|
|
253
|
+
FileUtils.mkdir_p File.dirname(dest)
|
|
254
|
+
FileUtils.cp abs, dest
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Flushed per entry, so an interrupted run is still fully revertible.
|
|
258
|
+
def note(entry)
|
|
259
|
+
@log.reject! { |row| row['path'] == entry.path }
|
|
260
|
+
@log.push 'path' => entry.path, 'op' => entry.op, 'after' => entry.current_sha1
|
|
261
|
+
|
|
262
|
+
FileUtils.mkdir_p @dir
|
|
263
|
+
File.write File.join(@dir, LOG), "#{JSON.pretty_generate(@log)}\n"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Undoes a run: created files go away, changed and deleted files come back
|
|
267
|
+
# from the copies. Returns the paths it touched.
|
|
268
|
+
def restore!
|
|
269
|
+
raise Error, "no backup at #{@dir}" if @log.empty?
|
|
270
|
+
|
|
271
|
+
@log.map do |row|
|
|
272
|
+
path = row['path']
|
|
273
|
+
abs = File.expand_path(path, @root)
|
|
274
|
+
|
|
275
|
+
if row['op'] == 'create'
|
|
276
|
+
FileUtils.rm_f abs
|
|
277
|
+
else
|
|
278
|
+
src = File.join(@dir, path.sub(%r{\A\./}, ''))
|
|
279
|
+
raise Error, "backup copy missing for #{path}" unless File.exist?(src)
|
|
280
|
+
FileUtils.mkdir_p File.dirname(abs)
|
|
281
|
+
FileUtils.cp src, abs
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
path
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def read_log
|
|
291
|
+
file = File.join(@dir, LOG)
|
|
292
|
+
File.exist?(file) ? JSON.parse(File.read(file)) : []
|
|
293
|
+
rescue JSON::ParserError
|
|
294
|
+
[]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The bundle file: what to do, how to prove it, and what to call the commit.
|
|
299
|
+
class Bundle
|
|
300
|
+
attr_reader :path, :slug, :goal, :entries, :verify_commands
|
|
301
|
+
|
|
302
|
+
def initialize(path, root: Dir.pwd)
|
|
303
|
+
@path = File.expand_path(path)
|
|
304
|
+
@root = File.expand_path(root)
|
|
305
|
+
raise Error, "no bundle at #{path}" unless File.file?(@path)
|
|
306
|
+
|
|
307
|
+
raw = parse
|
|
308
|
+
@slug = raw['slug'].to_s.empty? ? File.basename(@path, '.json') : raw['slug']
|
|
309
|
+
@goal = raw['goal'].to_s
|
|
310
|
+
@commit = raw['commit'] || {}
|
|
311
|
+
@verify_commands = Array(raw['verify'])
|
|
312
|
+
@entries = Array(raw['files']).map { |file| Entry.new(file, root: @root) }
|
|
313
|
+
|
|
314
|
+
raise Error, 'bundle lists no files' if @entries.empty?
|
|
315
|
+
raise Error, "duplicate path in bundle: #{duplicate}" if duplicate
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def backup_dir = File.join(dir, "#{slug}.bak")
|
|
319
|
+
def message_path = File.join(dir, "#{slug}.msg")
|
|
320
|
+
|
|
321
|
+
# Subject and body separated by the blank line git expects.
|
|
322
|
+
def commit_message
|
|
323
|
+
[@commit['subject'], @commit['body']].map { |part| part.to_s.strip }.reject(&:empty?).join("\n\n")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def write_commit_message!
|
|
327
|
+
return if commit_message.empty?
|
|
328
|
+
File.write message_path, "#{commit_message}\n"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
def dir = File.dirname(@path)
|
|
334
|
+
|
|
335
|
+
def parse
|
|
336
|
+
JSON.parse File.read(@path)
|
|
337
|
+
rescue JSON::ParserError => e
|
|
338
|
+
raise Error, "bundle is not valid json: #{e.message}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def duplicate
|
|
342
|
+
seen = {}
|
|
343
|
+
@entries.each do |entry|
|
|
344
|
+
return entry.path if seen[entry.path]
|
|
345
|
+
seen[entry.path] = true
|
|
346
|
+
end
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
Applied = Struct.new(:entry, :note)
|
|
352
|
+
Drifted = Struct.new(:entry, :reason)
|
|
353
|
+
Verify = Struct.new(:ok, :failed_command)
|
|
354
|
+
|
|
355
|
+
# What one run did. `exit_code` is the whole contract with the shell.
|
|
356
|
+
class Outcome
|
|
357
|
+
attr_reader :bundle, :applied, :drifted, :skipped
|
|
358
|
+
attr_accessor :verify
|
|
359
|
+
|
|
360
|
+
def initialize(bundle:, applied:, drifted:, skipped:, check_only:)
|
|
361
|
+
@bundle = bundle
|
|
362
|
+
@applied = applied
|
|
363
|
+
@drifted = drifted
|
|
364
|
+
@skipped = skipped
|
|
365
|
+
@check_only = check_only
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def check_only? = @check_only
|
|
369
|
+
def clean? = @drifted.empty?
|
|
370
|
+
def touched? = @applied.any?
|
|
371
|
+
|
|
372
|
+
def exit_code
|
|
373
|
+
return 10 unless clean?
|
|
374
|
+
return 20 if verify && !verify.ok
|
|
375
|
+
0
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Drives a bundle. Prints nothing - the caller owns the terminal.
|
|
380
|
+
class Runner
|
|
381
|
+
def initialize(bundle, root: Dir.pwd)
|
|
382
|
+
@bundle = bundle
|
|
383
|
+
@root = File.expand_path(root)
|
|
384
|
+
@backup = Backup.new(bundle.backup_dir, root: @root)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def apply(check_only: false)
|
|
388
|
+
applied, drifted, skipped = [], [], []
|
|
389
|
+
|
|
390
|
+
@bundle.entries.each do |entry|
|
|
391
|
+
next skipped.push(entry) if entry.already_applied?(@backup.record_for(entry))
|
|
392
|
+
|
|
393
|
+
reason = entry.drift_reason
|
|
394
|
+
next drifted.push(Drifted.new(entry, reason)) if reason
|
|
395
|
+
|
|
396
|
+
if check_only
|
|
397
|
+
problem = entry.dry_apply
|
|
398
|
+
next drifted.push(Drifted.new(entry, problem)) if problem
|
|
399
|
+
next applied.push(Applied.new(entry, 'would apply'))
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
failure = entry.apply!(@backup)
|
|
403
|
+
|
|
404
|
+
if failure
|
|
405
|
+
drifted.push Drifted.new(entry, failure)
|
|
406
|
+
else
|
|
407
|
+
@backup.note entry
|
|
408
|
+
applied.push Applied.new(entry, entry.past)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
@bundle.write_commit_message! if applied.any? && !check_only
|
|
413
|
+
|
|
414
|
+
Outcome.new(bundle: @bundle, applied:, drifted:, skipped:, check_only:)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Runs the verify commands in order, stopping at the first failure. Their
|
|
418
|
+
# output streams straight through - the caller wants to read it. Yields
|
|
419
|
+
# each command first so the caller can announce it.
|
|
420
|
+
def verify
|
|
421
|
+
@bundle.verify_commands.each do |cmd|
|
|
422
|
+
yield cmd if block_given?
|
|
423
|
+
return Verify.new(false, cmd) unless system(cmd)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
Verify.new(true, nil)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def revert = @backup.restore!
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# The grouped, sorted view of what a bundle would do: the answer to "what am
|
|
433
|
+
# I approving?". Holds no formatting - both renderers read it.
|
|
434
|
+
class Manifest
|
|
435
|
+
GROUPS = { 'create' => 'Created', 'change' => 'Changed', 'delete' => 'Deleted' }.freeze
|
|
436
|
+
|
|
437
|
+
Group = Struct.new(:op, :title, :files, :problems)
|
|
438
|
+
Row = Struct.new(:path, :note, :added, :removed)
|
|
439
|
+
|
|
440
|
+
def initialize(outcome)
|
|
441
|
+
@outcome = outcome
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def goal = @outcome.bundle.goal
|
|
445
|
+
def verify_commands = @outcome.bundle.verify_commands
|
|
446
|
+
def clean? = @outcome.drifted.empty?
|
|
447
|
+
|
|
448
|
+
def groups
|
|
449
|
+
@groups ||= GROUPS.filter_map do |op, title|
|
|
450
|
+
files = rows_for(op)
|
|
451
|
+
problems = @outcome.drifted.select { |drifted| drifted.entry.op == op }.sort_by { |d| d.entry.path }
|
|
452
|
+
next if files.empty? && problems.empty?
|
|
453
|
+
|
|
454
|
+
Group.new(op, title, files, problems)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Columns at which the clause and the counts start, so rows line up.
|
|
459
|
+
# Problem rows share the path column, or they run into their own message.
|
|
460
|
+
def width
|
|
461
|
+
@width ||= (groups.flat_map { |group| group.files.map(&:path) + group.problems.map { |d| d.entry.path } }
|
|
462
|
+
.map(&:length).max || 0) + 4
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def note_width
|
|
466
|
+
@note_width ||= (groups.flat_map(&:files).map { |file| file.note.length }.max || 0) + 4
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# "+23 -45" over every file in the plan.
|
|
470
|
+
def totals
|
|
471
|
+
files = groups.flat_map(&:files)
|
|
472
|
+
[files.sum(&:added), files.sum(&:removed)]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def tally
|
|
476
|
+
ready = @outcome.applied.size + @outcome.skipped.size
|
|
477
|
+
problems = @outcome.drifted.size
|
|
478
|
+
added, removed = totals
|
|
479
|
+
counted = "#{count(ready, 'file')}, +#{added} -#{removed}"
|
|
480
|
+
return "#{counted}, anchors resolve" if problems.zero?
|
|
481
|
+
|
|
482
|
+
"#{counted}, #{count(problems, 'problem')} - fix the plan before \"go\""
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
private
|
|
486
|
+
|
|
487
|
+
def rows_for(op)
|
|
488
|
+
ready = @outcome.applied.map(&:entry).select { |entry| entry.op == op }
|
|
489
|
+
.map { |entry| row_for(entry, entry.note) }
|
|
490
|
+
done = @outcome.skipped.select { |entry| entry.op == op }
|
|
491
|
+
.map { |entry| row_for(entry, [entry.note, '(already applied)'].reject(&:empty?).join(' ')) }
|
|
492
|
+
|
|
493
|
+
(ready + done).sort_by(&:path)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def row_for(entry, note)
|
|
497
|
+
added, removed = entry.delta
|
|
498
|
+
Row.new(entry.path, note, added, removed)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def count(number, noun)
|
|
502
|
+
"#{number} #{noun}#{'s' unless number == 1}"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# The manifest as markdown, for pasting into a reply. The file list goes in a
|
|
507
|
+
# ```diff fence: a renderer colours those rows by their leading sign, which
|
|
508
|
+
# is the only way to get green and red into pasted markdown.
|
|
509
|
+
class MarkdownReport
|
|
510
|
+
SIGNS = { 'create' => '+', 'change' => '!', 'delete' => '-' }.freeze
|
|
511
|
+
|
|
512
|
+
def initialize(outcome)
|
|
513
|
+
@manifest = Manifest.new(outcome)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def to_s = lines.join("\n")
|
|
517
|
+
|
|
518
|
+
def lines
|
|
519
|
+
rows = []
|
|
520
|
+
rows << "**#{@manifest.goal}**" << '' unless @manifest.goal.empty?
|
|
521
|
+
|
|
522
|
+
rows << '```diff'
|
|
523
|
+
@manifest.groups.each do |group|
|
|
524
|
+
sign = SIGNS[group.op]
|
|
525
|
+
group.files.each { |file| rows << "#{sign} #{columns(file)}" }
|
|
526
|
+
group.problems.each { |drifted| rows << "! #{drifted.entry.path.ljust(@manifest.width)}PROBLEM #{drifted.reason}" }
|
|
527
|
+
end
|
|
528
|
+
rows << '```' << ''
|
|
529
|
+
|
|
530
|
+
rows << "verify: #{@manifest.verify_commands.map { |cmd| "`#{cmd}`" }.join(', ')}" if @manifest.verify_commands.any?
|
|
531
|
+
rows << @manifest.tally
|
|
532
|
+
rows
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
private
|
|
536
|
+
|
|
537
|
+
def columns(file)
|
|
538
|
+
"#{file.path.ljust(@manifest.width)}#{file.note.ljust(@manifest.note_width)}+#{file.added} -#{file.removed}"
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Turns an Outcome into lines of [text, color] for the CLI to print. Kept
|
|
543
|
+
# apart from Runner so the wording is testable without a terminal.
|
|
544
|
+
class Report
|
|
545
|
+
# Colour per operation, so a group reads at a glance.
|
|
546
|
+
OP_COLORS = { 'create' => :green, 'change' => :yellow, 'delete' => :red }.freeze
|
|
547
|
+
PLAIN = ->(text, _color) { text }
|
|
548
|
+
|
|
549
|
+
# `paint` is injected rather than imported: the terminal owns colour, this
|
|
550
|
+
# class only says which words deserve it, and tests read plain strings.
|
|
551
|
+
def initialize(outcome, paint: PLAIN)
|
|
552
|
+
@outcome = outcome
|
|
553
|
+
@manifest = Manifest.new(outcome)
|
|
554
|
+
@paint = paint
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def lines
|
|
558
|
+
@outcome.check_only? ? manifest : result
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
private
|
|
562
|
+
|
|
563
|
+
# What a run did, in the past tense.
|
|
564
|
+
def result
|
|
565
|
+
rows = []
|
|
566
|
+
@outcome.applied.each { |applied| rows << [" ok #{applied.entry.path} (#{applied.note})", :green] }
|
|
567
|
+
@outcome.skipped.each { |entry| rows << [" ok #{entry.path} (already applied)", :gray] }
|
|
568
|
+
rows << ['', nil] if rows.any? && @outcome.drifted.any?
|
|
569
|
+
|
|
570
|
+
@outcome.drifted.each { |drifted| rows.concat drift_block(drifted) }
|
|
571
|
+
rows.concat summary
|
|
572
|
+
rows
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# What a run would do, for a human to approve. One line per file, grouped
|
|
576
|
+
# and sorted, each with its own clause. Never counts of lines touched -
|
|
577
|
+
# that says nothing about whether the change is the right one.
|
|
578
|
+
def manifest
|
|
579
|
+
rows = []
|
|
580
|
+
|
|
581
|
+
@manifest.groups.each do |group|
|
|
582
|
+
rows << [paint(group.title, OP_COLORS[group.op]), nil]
|
|
583
|
+
rows.concat group.files.map { |file| [file_row(file), nil] }
|
|
584
|
+
|
|
585
|
+
group.problems.each_with_index do |drifted, index|
|
|
586
|
+
rows << ['', nil] unless index.zero? && group.files.empty?
|
|
587
|
+
rows << [" #{paint("PROBLEM #{drifted.entry.path}", :red)}", nil]
|
|
588
|
+
rows << [" #{paint(drifted.reason, :yellow)}", nil]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
rows << ['', nil]
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
commands = @manifest.verify_commands
|
|
595
|
+
rows << ["#{paint('verify:', :magenta)} #{commands.join(', ')}", :gray] if commands.any?
|
|
596
|
+
rows << [@manifest.tally, @manifest.clean? ? :green : :red]
|
|
597
|
+
rows
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# " ./path clause +3 -1", padded before painting so the
|
|
601
|
+
# invisible escape codes never throw the columns off.
|
|
602
|
+
def file_row(file)
|
|
603
|
+
[' ',
|
|
604
|
+
paint(file.path.ljust(@manifest.width), :cyan),
|
|
605
|
+
file.note.ljust(@manifest.note_width),
|
|
606
|
+
paint("+#{file.added}", :green),
|
|
607
|
+
' ',
|
|
608
|
+
paint("-#{file.removed}", :red)].join
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def paint(text, color) = @paint.call(text, color)
|
|
612
|
+
|
|
613
|
+
def drift_block(drifted)
|
|
614
|
+
entry = drifted.entry
|
|
615
|
+
rows = [[" DRIFT #{entry.path}", :red],
|
|
616
|
+
[" #{drifted.reason}. apply this yourself against the current file:", :yellow]]
|
|
617
|
+
|
|
618
|
+
case entry.op
|
|
619
|
+
when 'delete'
|
|
620
|
+
rows << [' intent: delete this file - confirm that is still right', nil]
|
|
621
|
+
when 'create'
|
|
622
|
+
rows << [' intent: this content was planned as new - merge it in', nil]
|
|
623
|
+
rows << [' --- wanted content ---', :gray]
|
|
624
|
+
rows << [entry.content, nil]
|
|
625
|
+
rows << [' ----------------------', :gray]
|
|
626
|
+
when 'change'
|
|
627
|
+
entry.hunks.each do |hunk|
|
|
628
|
+
rows << [" intent: #{hunk['intent']}", nil] if hunk['intent']
|
|
629
|
+
rows << [' --- wanted old ---', :gray]
|
|
630
|
+
rows << [hunk['old'], nil]
|
|
631
|
+
rows << [' --- wanted new ---', :gray]
|
|
632
|
+
rows << [hunk['new'], nil]
|
|
633
|
+
rows << [' ------------------', :gray]
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
rows << ['', nil]
|
|
638
|
+
rows
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def summary
|
|
642
|
+
done = @outcome.applied.size + @outcome.skipped.size
|
|
643
|
+
rows = [[" #{done} applied, #{@outcome.drifted.size} needs you", nil]]
|
|
644
|
+
return rows unless @outcome.touched?
|
|
645
|
+
|
|
646
|
+
rows << [" backup: #{@outcome.bundle.backup_dir}/", :gray]
|
|
647
|
+
rows << [" revert: llm plan:revert #{@outcome.bundle.path}", :gray]
|
|
648
|
+
rows
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'io/console'
|
|
5
5
|
require 'pty'
|
|
6
|
+
require 'shellwords'
|
|
6
7
|
|
|
7
8
|
# Run a command in a PTY with the last prompts you typed pinned to the bottom
|
|
8
9
|
# rows of the screen, under a "prompt history" rule.
|
|
@@ -151,6 +152,66 @@ module LlmWrap
|
|
|
151
152
|
end
|
|
152
153
|
end
|
|
153
154
|
|
|
155
|
+
# The commands `llm wrap` offers when asked for no particular one. Plain
|
|
156
|
+
# text, one command per line - nothing to learn, editable with anything.
|
|
157
|
+
#
|
|
158
|
+
# A # comment is dropped, so a command can be parked without being deleted.
|
|
159
|
+
# A line with no letter in it is kept but not runnable: blanks and ---- rules
|
|
160
|
+
# stay visible in the picker to group the list, the cursor just steps over
|
|
161
|
+
# them. No executable name is spelled without a letter, so nothing real is
|
|
162
|
+
# caught by that.
|
|
163
|
+
module Config
|
|
164
|
+
DEFAULTS ||= [
|
|
165
|
+
'claude --dangerously-skip-permissions --continue',
|
|
166
|
+
'codex resume --last --dangerously-bypass-approvals-and-sandbox',
|
|
167
|
+
'grok --always-approve --continue'
|
|
168
|
+
].freeze
|
|
169
|
+
|
|
170
|
+
# A method rather than a constant for the same reason as Handoff.dir: the
|
|
171
|
+
# tests need somewhere else to write.
|
|
172
|
+
def self.path
|
|
173
|
+
ENV['LLM_WRAP_CONFIG'] || File.expand_path('~/.config/hammer/llm-wrap.txt')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.lines
|
|
177
|
+
return [] unless File.file?(path)
|
|
178
|
+
|
|
179
|
+
# Decide what to keep first, then tidy what survived - lstrip inside the
|
|
180
|
+
# test so an indented # is still a comment.
|
|
181
|
+
File.readlines(path, chomp: true)
|
|
182
|
+
.reject { |line| line.lstrip.start_with?('#') }
|
|
183
|
+
.map(&:strip)
|
|
184
|
+
rescue SystemCallError, IOError
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A row the picker shows but will not run. Nothing without a letter in it
|
|
189
|
+
# can name a program, which makes ---- and a blank line free to use as
|
|
190
|
+
# dividers without needing a syntax for them.
|
|
191
|
+
def self.runnable?(line)
|
|
192
|
+
line.match?(/[a-z]/i)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Split rather than hand the line over whole: PTY.spawn passes a
|
|
196
|
+
# one-element argv to /bin/sh, and a command out of this file should reach
|
|
197
|
+
# the wrapper the same way `llm wrap claude --foo` already does - as argv,
|
|
198
|
+
# no shell in between.
|
|
199
|
+
def self.argv(line)
|
|
200
|
+
Shellwords.split(line)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Writes DEFAULTS when there is nothing there yet - no file, or one that is
|
|
204
|
+
# only whitespace. A file holding just commented-out lines is left alone;
|
|
205
|
+
# those are someone's notes, not an empty file.
|
|
206
|
+
def self.seed
|
|
207
|
+
return false if File.file?(path) && !File.read(path).strip.empty?
|
|
208
|
+
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
File.write(path, "#{DEFAULTS.join("\n")}\n")
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
154
215
|
class Session
|
|
155
216
|
def initialize(argv, keep, origin = argv)
|
|
156
217
|
@argv = argv
|
data/recipes/llm.rb
CHANGED
|
@@ -7,6 +7,7 @@ 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)
|
|
11
12
|
|
|
12
13
|
Commands:
|
|
@@ -23,6 +24,7 @@ require 'set'
|
|
|
23
24
|
# checkout still load matching lib/, not an older installed gem copy).
|
|
24
25
|
_llm_root = File.dirname(File.realpath(__FILE__))
|
|
25
26
|
require File.join(_llm_root, 'lib/llm/usage')
|
|
27
|
+
require File.join(_llm_root, 'lib/llm/plan')
|
|
26
28
|
|
|
27
29
|
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/ai/memory')
|
|
28
30
|
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
@@ -40,10 +42,10 @@ task :usage do
|
|
|
40
42
|
`~/.cache/llm/claude-limits.json`, falling back to the OAuth usage API. `month` shows
|
|
41
43
|
Claude extra-usage credits (API only, and only when you've enabled them).
|
|
42
44
|
D
|
|
43
|
-
example '
|
|
44
|
-
example '
|
|
45
|
-
example '
|
|
46
|
-
example '
|
|
45
|
+
example 'usage'
|
|
46
|
+
example 'usage month'
|
|
47
|
+
example 'usage --json'
|
|
48
|
+
example 'usage --provider grok'
|
|
47
49
|
|
|
48
50
|
# :period is declared first on purpose — the parser fills un-set scalar opts
|
|
49
51
|
# from positionals in declaration order, so `llm usage month` must reach
|
|
@@ -108,27 +110,59 @@ task :wrap do
|
|
|
108
110
|
|
|
109
111
|
Put `--` before the wrapped command when it has flags of its own, otherwise they are
|
|
110
112
|
parsed as flags to `llm wrap`.
|
|
113
|
+
|
|
114
|
+
With no command at all, the commands in ~/.config/hammer/llm-wrap.txt are offered in
|
|
115
|
+
an arrow-key picker instead. That file is plain text, one command per line. A line
|
|
116
|
+
starting with # is dropped, and a line with no letter in it stays on screen but
|
|
117
|
+
cannot be picked - so blanks and ---- rules group the list without being in the way.
|
|
118
|
+
`--config` opens it in $EDITOR, writing a starting set the first time.
|
|
111
119
|
D
|
|
112
|
-
example '
|
|
113
|
-
example '
|
|
114
|
-
example '
|
|
120
|
+
example 'wrap'
|
|
121
|
+
example 'wrap --config'
|
|
122
|
+
example 'wrap claude'
|
|
123
|
+
example 'wrap -- claude --resume'
|
|
124
|
+
example 'wrap --lines 5 -- bash -l'
|
|
115
125
|
|
|
116
126
|
# `positional: false` is load-bearing: the parser fills un-set scalar opts
|
|
117
127
|
# from positionals in declaration order, so without it `llm wrap claude`
|
|
118
|
-
# would hand "claude" to --lines and die on the integer cast.
|
|
128
|
+
# would hand "claude" to --lines and die on the integer cast. `--config` is
|
|
129
|
+
# boolean, which the parser skips over, so it needs no such guard.
|
|
119
130
|
opt :lines, type: :integer, default: 3, positional: false,
|
|
120
131
|
desc: 'how many recent prompts to pin', placeholder: 'N'
|
|
132
|
+
opt :config, type: :boolean, desc: 'open the command list in $EDITOR'
|
|
121
133
|
|
|
122
134
|
proc do |opts|
|
|
123
|
-
cmd = Array(opts[:args])
|
|
124
|
-
error 'usage: llm wrap [--lines N] [--] <command> [args...]' if cmd.empty?
|
|
125
|
-
|
|
126
135
|
# Required lazily - llm.rb also runs as a per-prompt hook (`llm prompt
|
|
127
136
|
# hook`), which should not pay to load pty/io-console. `_llm_root` is a
|
|
128
137
|
# local from the file scope; the block closes over it (instance_exec
|
|
129
138
|
# rebinds self, not locals).
|
|
130
139
|
require File.join(_llm_root, 'lib/llm/wrap')
|
|
131
140
|
|
|
141
|
+
if opts[:config]
|
|
142
|
+
editor = ENV['EDITOR'] || ENV['VISUAL']
|
|
143
|
+
error '$EDITOR not set' unless editor
|
|
144
|
+
say.green "created #{LlmWrap::Config.path}" if LlmWrap::Config.seed
|
|
145
|
+
exit system(editor, LlmWrap::Config.path) ? 0 : 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
cmd = Array(opts[:args])
|
|
149
|
+
|
|
150
|
+
# Nothing asked for: offer the list rather than a usage error. The picker
|
|
151
|
+
# shows each line as it was written - re-joining a split argv would quote
|
|
152
|
+
# it back differently from what is in the file.
|
|
153
|
+
if cmd.empty?
|
|
154
|
+
LlmWrap::Config.seed
|
|
155
|
+
choices = LlmWrap::Config.lines
|
|
156
|
+
unless choices.any? { |line| LlmWrap::Config.runnable?(line) }
|
|
157
|
+
error "no commands in #{LlmWrap::Config.path} - add one with: llm wrap --config"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
idx = choose('wrap which command?', choices, skip: ->(line) { !LlmWrap::Config.runnable?(line) })
|
|
161
|
+
next say.gray('cancelled') unless idx
|
|
162
|
+
|
|
163
|
+
cmd = LlmWrap::Config.argv(choices[idx])
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
# The wrapper renames itself after the program it runs, so that a pane
|
|
133
167
|
# watcher can still see which agent is in there - which leaves nothing
|
|
134
168
|
# behind that says how the pane was started. This is that: the invocation
|
|
@@ -179,7 +213,7 @@ namespace :memory do
|
|
|
179
213
|
|
|
180
214
|
task :list do
|
|
181
215
|
desc 'List stored memories with type and one-line description'
|
|
182
|
-
example '
|
|
216
|
+
example 'memory list'
|
|
183
217
|
|
|
184
218
|
proc do
|
|
185
219
|
files = Dir[File.join(STORE, '*.md')].sort
|
|
@@ -199,7 +233,7 @@ namespace :memory do
|
|
|
199
233
|
|
|
200
234
|
task :read do
|
|
201
235
|
desc 'Print the full content of a memory (frontmatter + body)'
|
|
202
|
-
example '
|
|
236
|
+
example 'memory read user-role'
|
|
203
237
|
|
|
204
238
|
proc do |opts|
|
|
205
239
|
name = opts[:args].first
|
|
@@ -251,7 +285,7 @@ namespace :memory do
|
|
|
251
285
|
|
|
252
286
|
task :delete do
|
|
253
287
|
desc 'Delete a memory by name'
|
|
254
|
-
example '
|
|
288
|
+
example 'memory delete old-fact'
|
|
255
289
|
|
|
256
290
|
proc do |opts|
|
|
257
291
|
name = opts[:args].first
|
|
@@ -265,7 +299,7 @@ namespace :memory do
|
|
|
265
299
|
|
|
266
300
|
task :search do
|
|
267
301
|
desc 'Search memory bodies for a query string (case-insensitive)'
|
|
268
|
-
example '
|
|
302
|
+
example 'memory search react'
|
|
269
303
|
|
|
270
304
|
proc do |opts|
|
|
271
305
|
query = opts[:args].first
|
|
@@ -524,8 +558,8 @@ namespace :prompt do
|
|
|
524
558
|
|
|
525
559
|
task :expand do
|
|
526
560
|
desc 'Expand prompt token(s) and print the resulting context'
|
|
527
|
-
example '
|
|
528
|
-
example '
|
|
561
|
+
example 'prompt:expand :foo :bar'
|
|
562
|
+
example 'prompt:expand foo:'
|
|
529
563
|
|
|
530
564
|
proc do |opts|
|
|
531
565
|
input = opts[:args].join(' ')
|
|
@@ -561,3 +595,104 @@ namespace :prompt do
|
|
|
561
595
|
end
|
|
562
596
|
end
|
|
563
597
|
end
|
|
598
|
+
|
|
599
|
+
# The namespace's sibling task: `llm plan` explains the thing, `llm plan:*`
|
|
600
|
+
# does it. The command list underneath is hammer's own, not a copy.
|
|
601
|
+
task :plan do
|
|
602
|
+
desc 'How the /plan bundle apply works: logic, bundle format, exit codes'
|
|
603
|
+
example 'plan'
|
|
604
|
+
|
|
605
|
+
proc do
|
|
606
|
+
say LlmPlan.readme
|
|
607
|
+
say ''
|
|
608
|
+
self.class.print_help 'plan:'
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
namespace :plan do
|
|
613
|
+
# Helpers live inside the namespace block for the same reason as :memory -
|
|
614
|
+
# a top-level `helpers do` lands on the root class, which namespaces do not
|
|
615
|
+
# inherit from.
|
|
616
|
+
private
|
|
617
|
+
|
|
618
|
+
def load_bundle(opts)
|
|
619
|
+
path = opts[:args].first
|
|
620
|
+
error 'usage: llm plan:<apply|check|verify|revert> ./tmp/plan-[SLUG].json' unless path
|
|
621
|
+
LlmPlan::Bundle.new(path)
|
|
622
|
+
rescue LlmPlan::Error => e
|
|
623
|
+
error e.message
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Hammer owns colour (and turns it off for a non-tty), so hand its painter
|
|
627
|
+
# to the report rather than teaching the report about ANSI.
|
|
628
|
+
def render(outcome)
|
|
629
|
+
report = LlmPlan::Report.new(outcome, paint: Hammer::Shell.method(:paint))
|
|
630
|
+
report.lines.each { |text, color| say text, color }
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def run_verify(runner)
|
|
634
|
+
result = runner.verify { |cmd| say " > #{cmd}", :cyan }
|
|
635
|
+
result.ok ? say(' verify ok', :green) : say(" FAIL #{result.failed_command}", :red)
|
|
636
|
+
result
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
task :apply do
|
|
640
|
+
# Composed from plan.md rather than written out again - see `llm plan`.
|
|
641
|
+
desc ['Apply a /plan bundle: sha1-checked edits now, drift handed back to you.',
|
|
642
|
+
"The bundle, normally ./tmp/plan-[SLUG].json:\n\n#{LlmPlan.section('The bundle')}",
|
|
643
|
+
LlmPlan.section('Drift'),
|
|
644
|
+
LlmPlan.section('Exit codes')].join("\n\n")
|
|
645
|
+
example 'plan:apply ./tmp/plan-note-anchor.json'
|
|
646
|
+
|
|
647
|
+
proc do |opts|
|
|
648
|
+
bundle = load_bundle(opts)
|
|
649
|
+
runner = LlmPlan::Runner.new(bundle)
|
|
650
|
+
outcome = runner.apply
|
|
651
|
+
|
|
652
|
+
render outcome
|
|
653
|
+
outcome.verify = run_verify(runner) if outcome.clean?
|
|
654
|
+
|
|
655
|
+
exit outcome.exit_code
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
task :check do
|
|
660
|
+
desc ['Dry run: the summary to read before approving a plan. Writes nothing.',
|
|
661
|
+
LlmPlan.section('Summary')].join("\n\n")
|
|
662
|
+
example 'plan:check ./tmp/plan-note-anchor.json'
|
|
663
|
+
example 'plan:check --md ./tmp/plan-note-anchor.json'
|
|
664
|
+
|
|
665
|
+
opt :md, type: :boolean, desc: 'emit the summary as markdown, to paste into a reply'
|
|
666
|
+
|
|
667
|
+
proc do |opts|
|
|
668
|
+
outcome = LlmPlan::Runner.new(load_bundle(opts)).apply(check_only: true)
|
|
669
|
+
opts[:md] ? puts(LlmPlan::MarkdownReport.new(outcome).to_s) : render(outcome)
|
|
670
|
+
exit outcome.exit_code
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
task :verify do
|
|
675
|
+
desc <<~D
|
|
676
|
+
Run only the bundle's verify commands.
|
|
677
|
+
|
|
678
|
+
For after you have closed a drift by hand, or fixed what a failing verify
|
|
679
|
+
caught. Exit 0 green, 20 failed.
|
|
680
|
+
D
|
|
681
|
+
example 'plan:verify ./tmp/plan-note-anchor.json'
|
|
682
|
+
|
|
683
|
+
proc do |opts|
|
|
684
|
+
exit run_verify(LlmPlan::Runner.new(load_bundle(opts))).ok ? 0 : 20
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
task :revert do
|
|
689
|
+
desc 'Undo an applied bundle from <slug>.bak: restore changed and deleted files, remove created ones.'
|
|
690
|
+
example 'plan:revert ./tmp/plan-note-anchor.json'
|
|
691
|
+
|
|
692
|
+
proc do |opts|
|
|
693
|
+
LlmPlan::Runner.new(load_bundle(opts)).revert.each { |path| say " restored #{path}", :green }
|
|
694
|
+
rescue LlmPlan::Error => e
|
|
695
|
+
error e.message
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
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.22
|
|
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-09 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"
|