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.
@@ -0,0 +1,660 @@
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. Taken
459
+ # from every planned path, so problem rows share the column too.
460
+ def width
461
+ @width ||= (@outcome.bundle.entries.map { |entry| entry.path.length }.max || 0) + 4
462
+ end
463
+
464
+ def note_width
465
+ @note_width ||= (groups.flat_map(&:files).map { |file| file.note.length }.max || 0) + 4
466
+ end
467
+
468
+ # "+23 -45" over every file in the plan.
469
+ def totals
470
+ files = groups.flat_map(&:files)
471
+ [files.sum(&:added), files.sum(&:removed)]
472
+ end
473
+
474
+ def tally
475
+ ready = @outcome.applied.size + @outcome.skipped.size
476
+ problems = @outcome.drifted.size
477
+ added, removed = totals
478
+ counted = "#{count(ready, 'file')}, +#{added} -#{removed}"
479
+ return "#{counted}, anchors resolve" if problems.zero?
480
+
481
+ "#{counted}, #{count(problems, 'problem')} - fix the plan before \"go\""
482
+ end
483
+
484
+ private
485
+
486
+ def rows_for(op)
487
+ ready = @outcome.applied.map(&:entry).select { |entry| entry.op == op }
488
+ .map { |entry| row_for(entry, entry.note) }
489
+ done = @outcome.skipped.select { |entry| entry.op == op }
490
+ .map { |entry| row_for(entry, [entry.note, '(already applied)'].reject(&:empty?).join(' ')) }
491
+
492
+ (ready + done).sort_by(&:path)
493
+ end
494
+
495
+ def row_for(entry, note)
496
+ added, removed = entry.delta
497
+ Row.new(entry.path, note, added, removed)
498
+ end
499
+
500
+ def count(number, noun)
501
+ "#{number} #{noun}#{'s' unless number == 1}"
502
+ end
503
+ end
504
+
505
+ # The manifest as markdown, for pasting into a reply. The file list goes in a
506
+ # ```diff fence: a renderer colours those rows by their leading sign, which
507
+ # is the only way to get green and red into pasted markdown.
508
+ class MarkdownReport
509
+ SIGNS = { 'create' => '+', 'change' => '!', 'delete' => '-' }.freeze
510
+
511
+ def initialize(outcome)
512
+ @manifest = Manifest.new(outcome)
513
+ end
514
+
515
+ def to_s = lines.join("\n")
516
+
517
+ def lines
518
+ rows = []
519
+ rows << "**#{@manifest.goal}**" << '' unless @manifest.goal.empty?
520
+
521
+ rows << '```diff'
522
+ @manifest.groups.each do |group|
523
+ sign = SIGNS[group.op]
524
+ group.files.each { |file| rows << "#{sign} #{columns(file)}" }
525
+ group.problems.each { |drifted| rows << "! #{drifted.entry.path.ljust(@manifest.width)}PROBLEM #{drifted.reason}" }
526
+ end
527
+ rows << '```' << ''
528
+
529
+ rows << "verify: #{@manifest.verify_commands.map { |cmd| "`#{cmd}`" }.join(', ')}" if @manifest.verify_commands.any?
530
+ rows << @manifest.tally
531
+ rows
532
+ end
533
+
534
+ private
535
+
536
+ def columns(file)
537
+ "#{file.path.ljust(@manifest.width)}#{file.note.ljust(@manifest.note_width)}+#{file.added} -#{file.removed}"
538
+ end
539
+ end
540
+
541
+ # Turns an Outcome into lines of [text, color] for the CLI to print. Kept
542
+ # apart from Runner so the wording is testable without a terminal.
543
+ class Report
544
+ # Colour per operation, so a group reads at a glance.
545
+ OP_COLORS = { 'create' => :green, 'change' => :yellow, 'delete' => :red }.freeze
546
+ PLAIN = ->(text, _color) { text }
547
+
548
+ # `paint` is injected rather than imported: the terminal owns colour, this
549
+ # class only says which words deserve it, and tests read plain strings.
550
+ def initialize(outcome, paint: PLAIN)
551
+ @outcome = outcome
552
+ @manifest = Manifest.new(outcome)
553
+ @paint = paint
554
+ end
555
+
556
+ def lines
557
+ @outcome.check_only? ? manifest : result
558
+ end
559
+
560
+ private
561
+
562
+ # What a run did, in the past tense. Sorted by path, same as the manifest -
563
+ # a landed list is read the same way a planned one is.
564
+ def result
565
+ rows = []
566
+ landed = @outcome.applied.map { |applied| [applied.entry, applied.note] } +
567
+ @outcome.skipped.map { |entry| [entry, 'already applied'] }
568
+
569
+ unless landed.empty?
570
+ rows << [paint('Landed', :green), nil]
571
+ landed.sort_by { |entry, _| entry.path }.each { |entry, note| rows << [landed_row(entry, note), nil] }
572
+ rows << ['', nil]
573
+ end
574
+
575
+ @outcome.drifted.each { |drifted| rows.concat drift_block(drifted) }
576
+ rows.concat summary
577
+ rows
578
+ end
579
+
580
+ def landed_row(entry, note)
581
+ " #{paint(entry.path.ljust(@manifest.width), :cyan)}#{paint(note, :gray)}"
582
+ end
583
+
584
+ # What a run would do, for a human to approve. One line per file, grouped
585
+ # and sorted, each with its own clause. Never counts of lines touched -
586
+ # that says nothing about whether the change is the right one.
587
+ def manifest
588
+ rows = []
589
+
590
+ @manifest.groups.each do |group|
591
+ rows << [paint(group.title, OP_COLORS[group.op]), nil]
592
+ rows.concat group.files.map { |file| [file_row(file), nil] }
593
+
594
+ group.problems.each_with_index do |drifted, index|
595
+ rows << ['', nil] unless index.zero? && group.files.empty?
596
+ rows << [" #{paint("PROBLEM #{drifted.entry.path}", :red)}", nil]
597
+ rows << [" #{paint(drifted.reason, :yellow)}", nil]
598
+ end
599
+
600
+ rows << ['', nil]
601
+ end
602
+
603
+ commands = @manifest.verify_commands
604
+ rows << ["#{paint('verify:', :magenta)} #{commands.join(', ')}", :gray] if commands.any?
605
+ rows << [@manifest.tally, @manifest.clean? ? :green : :red]
606
+ rows
607
+ end
608
+
609
+ # " ./path clause +3 -1", padded before painting so the
610
+ # invisible escape codes never throw the columns off.
611
+ def file_row(file)
612
+ [' ',
613
+ paint(file.path.ljust(@manifest.width), :cyan),
614
+ file.note.ljust(@manifest.note_width),
615
+ paint("+#{file.added}", :green),
616
+ ' ',
617
+ paint("-#{file.removed}", :red)].join
618
+ end
619
+
620
+ def paint(text, color) = @paint.call(text, color)
621
+
622
+ def drift_block(drifted)
623
+ entry = drifted.entry
624
+ rows = [[" DRIFT #{entry.path}", :red],
625
+ [" #{drifted.reason}. apply this yourself against the current file:", :yellow]]
626
+
627
+ case entry.op
628
+ when 'delete'
629
+ rows << [' intent: delete this file - confirm that is still right', nil]
630
+ when 'create'
631
+ rows << [' intent: this content was planned as new - merge it in', nil]
632
+ rows << [' --- wanted content ---', :gray]
633
+ rows << [entry.content, nil]
634
+ rows << [' ----------------------', :gray]
635
+ when 'change'
636
+ entry.hunks.each do |hunk|
637
+ rows << [" intent: #{hunk['intent']}", nil] if hunk['intent']
638
+ rows << [' --- wanted old ---', :gray]
639
+ rows << [hunk['old'], nil]
640
+ rows << [' --- wanted new ---', :gray]
641
+ rows << [hunk['new'], nil]
642
+ rows << [' ------------------', :gray]
643
+ end
644
+ end
645
+
646
+ rows << ['', nil]
647
+ rows
648
+ end
649
+
650
+ def summary
651
+ done = @outcome.applied.size + @outcome.skipped.size
652
+ rows = [[" #{done} applied, #{@outcome.drifted.size} needs you", nil]]
653
+ return rows unless @outcome.touched?
654
+
655
+ rows << [" backup: #{@outcome.bundle.backup_dir}/", :gray]
656
+ rows << [" revert: llm plan:revert #{@outcome.bundle.path}", :gray]
657
+ rows
658
+ end
659
+ end
660
+ end