lux-hammer 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/lib/hammer/shell.rb +33 -12
- data/recipes/lib/llm/plan.rb +660 -0
- data/recipes/lib/llm/wrap.rb +558 -87
- data/recipes/llm.rb +445 -17
- metadata +3 -2
|
@@ -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
|