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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84a9d6339d8a34112c31a210823398ea9122959fff9d3522562f840147e8a130
4
- data.tar.gz: f7483553452c4e33d18dad6d9d4e2b764cb13a49a4080ea90f4227689f9fc211
3
+ metadata.gz: df3eb16fdf398c03d043585e48e4f9792d09b4427f0a0733eefa9725f1f97b0d
4
+ data.tar.gz: d36961cbeb0e10d44fa5119629a436aeed2d16fa384fbc0256dfb9cf1f9893ec
5
5
  SHA512:
6
- metadata.gz: e5158a762b7cb292535f58e33b0a8ae84c2f9affe473501cce701784d5a690bebf5c187e3c588df66821a5cd3f3177f83d655149e84426ea8e9a12c578e7e5c8
7
- data.tar.gz: 5711d908d489770a4ede0507224f0a6cf3dbe761edddaed0f6c4591b0e1a1e070e4727f8000829c861cc215be8baf238933b6591d996bb8ee500742d70d22aa5
6
+ metadata.gz: 8429a1a3a276bca73309522c57c78f3283a78ed327cb0d4354757c3bccd06bf5a0202af888f5ebfa8e66c01850cb4cf927317b8c68970a18b3a9370ff5a70516
7
+ data.tar.gz: 68ffb3baf274099eae5e3c3398f77bbda5163c3867980772c737e06d211a8ce26de08702f51e53b8d27c067703eedaac482f5e4f7c2b133e638272ef10b319c6
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.21
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
- def choose(prompt, items)
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 = 0
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 == selected ? paint("> #{item}", highlight) : " #{item}"
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 = (selected - 1) % items.size
130
- when 'B' then selected = (selected + 1) % items.size
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 = (selected - 1) % items.size
137
- when 'j' then selected = (selected + 1) % items.size
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
- def choose_numbered(items)
150
- items.each_with_index { |item, i| puts " #{i + 1}) #{item}" }
151
- print paint("select [1-#{items.size}]: ", :cyan)
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, items.size - 1) ? idx : nil
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
@@ -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 'llm usage'
44
- example 'llm usage month'
45
- example 'llm usage --json'
46
- example 'llm usage --provider grok'
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 'llm wrap claude'
113
- example 'llm wrap -- claude --resume'
114
- example 'llm wrap --lines 5 -- bash -l'
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 'llm memory list'
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 'llm memory read user-role'
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 'llm memory delete old-fact'
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 'llm memory search react'
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 'llm prompt:expand :foo :bar'
528
- example 'llm prompt:expand foo:'
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.21
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-08 00:00:00.000000000 Z
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"