lyman 0.1.0

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.
data/docs/vision.md ADDED
@@ -0,0 +1,230 @@
1
+ # Lyman — Vision
2
+
3
+ Lyman is a composable agentic harness — and a framework for building harnesses —
4
+ written in Ruby and built on the [shifty](https://github.com/joelhelbling/shifty)
5
+ gem.
6
+
7
+ This document captures *why* lyman exists, the values guiding its creation, the
8
+ architectural decisions made so far, and the questions still open. It is meant to
9
+ be a compass, not a spec. It should stay legible.
10
+
11
+ ---
12
+
13
+ ## Why lyman exists
14
+
15
+ Frontier models enjoy an enormous advantage that has nothing to do with their
16
+ weights: a massive user base feeding them usage data, context, and rapid
17
+ real-world validation. Anyone building their own agentic system on **open-weight
18
+ models — or models they train themselves — starts from a less mature place and
19
+ with far less data.**
20
+
21
+ For that builder, the one edge available is **iteration speed**. If experimenting
22
+ is cheap and fast, a small team can close a lot of the maturity gap through sheer
23
+ cycles.
24
+
25
+ **Lyman exists to make that iteration fast.** It targets the local-inference
26
+ sector: individuals and companies building narrow, purpose-built agentic
27
+ workflows on models they run or train themselves. The bet is that shifty's
28
+ pipeline model — flexible, composable, extensible — enables the rapid
29
+ experimentation this audience needs.
30
+
31
+ ### The name
32
+
33
+ *Lyman*, as in the Lyman series — a nod to the physicist, and to the concept of
34
+ **redshift**, which in turn nods to the **Ruby** language and the **shifty** gem.
35
+ (A series is also, fittingly, a sequence of discrete lines — like a sequence of
36
+ pipeline stages.)
37
+
38
+ ---
39
+
40
+ ## Core principles
41
+
42
+ Two principles are the heart of lyman. Nearly every design decision should be
43
+ traceable to one of them.
44
+
45
+ ### 1. Legibility — one paradigm, applied everywhere
46
+
47
+ Everything is a **pipeline of workers**. One mental model, used at every level.
48
+ Shifty is not widely known, so lyman should make the paradigm *easy to pick up*
49
+ rather than hiding it behind abstractions.
50
+
51
+ ### 2. Guts on the outside — radical transparency
52
+
53
+ Nothing important should happen in a place you can't see, name, or splice into.
54
+ This is deliberately the opposite of the frontier-SDK ethos, where the agent loop
55
+ is a black box configured from the edges. In lyman, the workings are exposed.
56
+
57
+ ### The design razor
58
+
59
+ When a decision is genuinely balanced, **favor the choice that lets a user see and
60
+ change behavior faster** — even at the cost of convenience or polish. For a
61
+ data-poor tinkerer, legibility and transparency are not aesthetics; they are what
62
+ makes fast iteration *possible*.
63
+
64
+ ---
65
+
66
+ ## Who lyman is for, and what it ships
67
+
68
+ - **A developer's tool.** Aimed at a Ruby developer willing to write worker blocks
69
+ and wire pipelines. **Not a no-code tool.**
70
+ - **DSLs are used sparingly.** DSLs tend to obscure inner workings, which fights
71
+ "guts on the outside." Convenience should not cost transparency.
72
+ - **Ships with at least one competent harness** that can be altered, extended, or
73
+ wholly replaced — a strong starting point, not a cage.
74
+ - **A kit of parts you recombine.** Lyman is a set of swappable worker/gang parts
75
+ plus **one legible pipeline-definition script** that wires them together. The
76
+ pipeline file is legible *precisely because* the workers are defined elsewhere,
77
+ not inline — it reads like an assembly diagram.
78
+ - **Scaffolding is a co-equal concept.** "Lyman gives you a working harness *and*
79
+ the means to spin up new ones." With good docs/skills, agentic tools (e.g.
80
+ Claude Code) should be able to author lyman harnesses easily.
81
+ - **Delivered as a pure generator** (see
82
+ [design/deployment.md](design/deployment.md)): lyman plants legible source
83
+ into the client project rather than being a runtime framework dependency.
84
+ Planted modules are manifest-tracked and individually upgradeable; the
85
+ harness is the user's from day one. The unit of upgrade is the unit of
86
+ extraction.
87
+
88
+ ---
89
+
90
+ ## Architecture decisions so far
91
+
92
+ ### Multiple pipelines, multiple item types
93
+
94
+ Lyman is not one pipeline; it is a **composition of pipelines**, each with its own
95
+ natural item type, nested or feeding into one another. Shifty's `Gang` — a
96
+ pipeline treated as a single worker — is the mechanism.
97
+
98
+ ### The conversation spine
99
+
100
+ - The central pipeline's item is a **conversation turn**.
101
+ - A turn **carries the whole conversation so far** — otherwise it isn't a
102
+ conversation.
103
+ - **Context-assembly asymmetry:** on the first turn there is no system prompt or
104
+ prior history, so one is built just-in-time. On later turns, the new prompt is
105
+ appended to the existing series and passed through. A single "context assembler"
106
+ stage at the head of the spine can own this rule.
107
+
108
+ ### State lives in the enclosing scope (the shell)
109
+
110
+ - One pass through the spine = **one turn**. When a turn finishes, the updated
111
+ conversation (now including the reply) must survive until the next input.
112
+ - That state is held in the **shell** — the enclosing scope, a pattern that
113
+ emerges naturally from shifty. A shell is **state + a process**: an
114
+ environment holding the conversation, plus a driving process that is usually
115
+ just a `while` loop (or none at all, for a one-shot agent). The shell is
116
+ *deliberately boring* — if a shell is getting interesting, something in it
117
+ probably belongs in a worker. Durability (disk/db) is then an **optional,
118
+ splice-in side-worker**, not a built-in assumption.
119
+ - **Not chat-shaped — turn-shaped.** The spine must not hard-code a human
120
+ REPL/TUI as its home. A human REPL is one shell; an autonomous agent (e.g. an
121
+ email triager whose "turn" is an incoming email) is another. Human-in-the-loop
122
+ and autonomous agents are *the same architecture with different shells.* A TUI
123
+ is not a shell — it runs in parallel with the pipeline, so UI concerns don't
124
+ colonize the shell.
125
+
126
+ ### The LLM/tool cycle is its own gang
127
+
128
+ - Inference is a **swappable sub-pipeline, one level down** from the spine. From
129
+ the spine's perspective, "produce the assistant's response for this turn" is one
130
+ step — *whether that took one model call or nine is an internal detail.*
131
+ - The multi-step agentic loop (model → tool → model → …) lives **inside the
132
+ circuit pattern** (see [design/circuit-pattern.md](design/circuit-pattern.md)):
133
+ a linear sub-pipeline of stock shifty parts that churns until the model stops
134
+ calling tools, then hands **one finished turn** back to the spine.
135
+ - Much of a tinkerer's work will happen on this sub-loop — so while it is
136
+ encapsulated as one node to the spine, it must remain **fully inspectable and
137
+ splice-able** on demand. Encapsulated by default, transparent when opened.
138
+
139
+ > **Note on encapsulation:** worker isolation tends to produce OO-like
140
+ > encapsulation of phases and responsibilities. This is an *observed emerging
141
+ > pattern*, not a design imperative. Do not over-index on it.
142
+
143
+ ### Model transport
144
+
145
+ - **OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, llama.cpp, …) are the
146
+ starting point — a de-facto industry standard.
147
+ - The transport itself is **just a swappable worker (or gang)**. Other transports
148
+ are alternate implementations of the same seam.
149
+
150
+ ### Tool calling
151
+
152
+ - Native tool calling is important and comes first.
153
+ - Support for models *without* native tool support (prompt-and-parse emulation) is
154
+ **not an early priority** — but the tool-calling stage should be a **swappable
155
+ worker/gang** so "native" and "emulated" are two implementations of one seam.
156
+ This costs nothing now and avoids a later refactor.
157
+
158
+ ### Dependency isolation
159
+
160
+ - Shifty's extreme worker isolation + Ruby's duck typing mean most dependencies
161
+ are the concern of **a single worker or gang**.
162
+ - **Confine each dependency to the worker that needs it.** Use your favorite
163
+ libraries, but in such a way that only the worker requiring one even knows it
164
+ exists. This is dependency management in the spirit of "guts on the outside":
165
+ each part carries its own guts; the pipeline stays clean.
166
+
167
+ ### Topology as data (a lyman value-add on top of shifty)
168
+
169
+ For a workflow to be *depicted*, *scaffolded from*, or *reasoned about by another
170
+ tool*, lyman must be able to **describe its own topology as data** — its stages,
171
+ their names/tags, and their nesting into gangs — separately from executing it as
172
+ live fibers. Shifty gives `tags` and a `Roster`, but not a full introspectable
173
+ topology; providing one is a genuine lyman value-add.
174
+
175
+ This directly serves the **visualization vision**: a generated, ideally
176
+ interactive diagram of a workflow, where the whole LLM/tool loop shows as a single
177
+ node that a viewer can click to drill into its individual workers —
178
+ progressive disclosure of the same "encapsulated but inspectable" idea.
179
+
180
+ ---
181
+
182
+ ## Deferred / user-composed (not solved first)
183
+
184
+ These are real and valued, but lyman does not need to solve them up front — and in
185
+ many cases they are "just workers the user composes," consistent with the
186
+ multi-pipeline instinct.
187
+
188
+ - **Observability & tracing** — a lot is achievable with logging `side_worker`s.
189
+ Not a first-order problem to solve.
190
+ - **Evaluation** — comparing models or measuring whether a change helped is
191
+ valuable to the mission, but is user-composed / later.
192
+ - **Failure, retry, fallback, rollback** — shifty provides little here by design;
193
+ these are workers the user composes (e.g. "model timed out → try the smaller
194
+ local model"). An idea under rumination: **immutability** as an enabler of
195
+ rollback. Logged as a thought, not a commitment.
196
+ - **Streaming** — an attractive nice-to-have (esp. for a TUI). Feedback to a UI can
197
+ likely ride on a `side_worker`. How token streaming best fits shifty's
198
+ one-item-at-a-time paradigm needs its own examination.
199
+
200
+ ---
201
+
202
+ ## Non-goals
203
+
204
+ - **Not a no-code tool.**
205
+ - **Not chasing parity** with frontier-model harnesses or popular tools (OpenCode,
206
+ etc.) for parity's sake. Lyman will not brag about feature checklists.
207
+ - **Not trying to abstract over every provider's quirks** as a headline goal.
208
+ - Bundled integrations may come **much, much later** — worth considering, but not
209
+ the point now.
210
+
211
+ **What lyman *does* focus on:** rapidly building a **narrow, purpose-built agentic
212
+ workflow tailored to the specific problems it solves and the specific models it
213
+ uses.** Shifty opens a vast solution space that lyman need not ship, because people
214
+ will find it easy to build those solutions *with* lyman.
215
+
216
+ ---
217
+
218
+ ## Open questions
219
+
220
+ 1. ~~**Cycles in a linear-pull model.**~~ **Resolved** — see
221
+ [design/circuit-pattern.md](design/circuit-pattern.md). The model⇄tool cycle
222
+ is the **circuit pattern**: stock shifty parts (source-from-queue,
223
+ splitter/batch fan-out/fan-in, side-worker back-edge, filter-worker loop
224
+ condition), with the queue living visibly in the shell's scope. No third
225
+ primitive; N rounds happen inside a single `shift` via shifty's own demand
226
+ semantics. Includes the item-as-control discipline: *an item may tell a
227
+ worker whether to act, never which of several things to do.*
228
+ 2. **Streaming in the pipeline paradigm** (see above).
229
+ 3. **Immutability / rollback** — is there a clean way to give workflows rollback
230
+ semantics, possibly via immutable items?
data/exe/lyman ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "lyman/cli"
4
+ Lyman::CLI::Root.start
data/harness/chat.rb ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # The shell: state + a process. Deliberately boring.
4
+ #
5
+ # This file is the one legible wiring script — the circuit pattern from
6
+ # docs/design/circuit-pattern.md, wired filter-in: one `pipeline.shift`
7
+ # per turn, with all model⇄tool rounds happening inside the call.
8
+ #
9
+ # Everything the model does streams to the terminal as it happens —
10
+ # pre-tool narration, tool calls, the final answer. Fitting the display
11
+ # to the model (like hiding a <think> block) is this shell's job, not
12
+ # the library's.
13
+
14
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
15
+ require "lyman"
16
+
17
+ # This is a top-level wiring script by design; mixing the DSL into main
18
+ # is the point, not an accident.
19
+ include Shifty::DSL # standard:disable Style/MixinUsage
20
+
21
+ $stdout.sync = true
22
+
23
+ BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
24
+ MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
25
+
26
+ # ── Tools: schema and handler side by side, guts on the outside ────────────
27
+ TOOLS = {
28
+ "current_time" => {
29
+ schema: {
30
+ "type" => "function",
31
+ "function" => {
32
+ "name" => "current_time",
33
+ "description" => "Returns the current local date and time",
34
+ "parameters" => {"type" => "object", "properties" => {}, "required" => []}
35
+ }
36
+ },
37
+ handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
38
+ }
39
+ }
40
+
41
+ schemas = TOOLS.values.map { |tool| tool[:schema] }
42
+ handlers = TOOLS.transform_values { |tool| tool[:handler] }
43
+
44
+ # ── Display: fitting this harness to its models ─────────────────────────────
45
+
46
+ # Thinking models prefix replies with <think>...</think> (the worker
47
+ # normalizes separate-reasoning-field servers to the same convention). When
48
+ # the reply arrives one fragment at a time we can't regex the whole thing, so
49
+ # this streams a short preview of the thinking — the first few lines — then
50
+ # elides the rest, closing with "...</think>" once the model stops thinking.
51
+ class ThinkFilter
52
+ OPEN = "<think>"
53
+ CLOSE = "</think>"
54
+ SNIPPET_LINES = 3
55
+ SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
56
+
57
+ def initialize
58
+ @state = :start
59
+ @buffer = +""
60
+ @lines = 0
61
+ @chars = 0
62
+ @truncated = false
63
+ end
64
+
65
+ # Returns the printable portion of this fragment.
66
+ def filter(fragment)
67
+ @buffer << fragment
68
+ case @state
69
+ when :start then filter_start
70
+ when :thinking then filter_thinking
71
+ when :after_think then filter_after_think
72
+ when :passing then take_buffer
73
+ end
74
+ end
75
+
76
+ # Call when the message is complete: releases anything still held back
77
+ # (e.g. a reply that was nothing but "<thin"), and closes a think block
78
+ # the model never closed itself.
79
+ def flush
80
+ case @state
81
+ when :start then take_buffer
82
+ when :thinking then (@truncated ? "..." : "") + CLOSE
83
+ else ""
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def take_buffer
90
+ out = @buffer
91
+ @buffer = +""
92
+ out
93
+ end
94
+
95
+ def filter_start
96
+ if @buffer.start_with?(OPEN)
97
+ @state = :thinking
98
+ @buffer = @buffer[OPEN.length..]
99
+ OPEN + filter_thinking
100
+ elsif OPEN.start_with?(@buffer)
101
+ "" # could still become <think>; wait for more
102
+ else
103
+ @state = :passing
104
+ take_buffer
105
+ end
106
+ end
107
+
108
+ def filter_thinking
109
+ if (idx = @buffer.index(CLOSE))
110
+ thought = @buffer[0...idx]
111
+ @buffer = @buffer[(idx + CLOSE.length)..]
112
+ @state = :after_think
113
+ snippet(thought) + (@truncated ? "..." : "") + CLOSE + "\n\n" + filter_after_think
114
+ else
115
+ # Keep any tail that could be the start of CLOSE split across
116
+ # fragments; preview the rest.
117
+ keep = partial_close_suffix
118
+ thought = @buffer[0, @buffer.length - keep.length]
119
+ @buffer = keep
120
+ snippet(thought)
121
+ end
122
+ end
123
+
124
+ # The leading portion of the thought that fits the preview budget.
125
+ def snippet(text)
126
+ return "" if @truncated
127
+ out = +""
128
+ text.each_char do |ch|
129
+ if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
130
+ @truncated = true
131
+ break
132
+ end
133
+ out << ch
134
+ @chars += 1
135
+ @lines += 1 if ch == "\n"
136
+ end
137
+ out
138
+ end
139
+
140
+ # We print "</think>\n\n" ourselves, so swallow the model's own
141
+ # whitespace between the close tag and the reply.
142
+ def filter_after_think
143
+ stripped = @buffer.lstrip
144
+ if stripped.empty?
145
+ @buffer = +""
146
+ ""
147
+ else
148
+ @state = :passing
149
+ @buffer = stripped
150
+ take_buffer
151
+ end
152
+ end
153
+
154
+ def partial_close_suffix
155
+ (1...CLOSE.length).reverse_each do |len|
156
+ tail = @buffer[-len, len]
157
+ return tail if tail && CLOSE.start_with?(tail)
158
+ end
159
+ +""
160
+ end
161
+ end
162
+
163
+ # Streams one round's content to the terminal, printing the model label
164
+ # before the first visible text so silent rounds (pure tool calls) don't
165
+ # leave an empty prompt behind.
166
+ class RoundPrinter
167
+ def initialize(label)
168
+ @label = label
169
+ end
170
+
171
+ def start_round
172
+ @filter = ThinkFilter.new
173
+ @printed = false
174
+ end
175
+
176
+ def delta(text)
177
+ emit(@filter.filter(text))
178
+ end
179
+
180
+ def finish_round
181
+ emit(@filter.flush)
182
+ puts if @printed
183
+ end
184
+
185
+ private
186
+
187
+ def emit(text)
188
+ return if text.empty?
189
+ print "\n#{@label}> " unless @printed
190
+ @printed = true
191
+ print text
192
+ end
193
+ end
194
+
195
+ # ── Shell state ─────────────────────────────────────────────────────────────
196
+ conversation = Lyman::Conversation.new(
197
+ system_prompt: "You are a helpful assistant. Keep replies brief."
198
+ )
199
+ rounds = [] # the circuit's queue — visible right here, not smuggled
200
+ printer = RoundPrinter.new(MODEL)
201
+
202
+ # ── The circuit ─────────────────────────────────────────────────────────────
203
+ pipeline =
204
+ source_worker { rounds.shift } |
205
+ side_worker { |_c| printer.start_round } |
206
+ Lyman::Workers.chat_completion(
207
+ base_url: BASE_URL, model: MODEL, tools: schemas,
208
+ on_delta: printer.method(:delta)
209
+ ) |
210
+ side_worker { |_c| printer.finish_round } |
211
+ relay_worker { |c|
212
+ c.finish! if c.pending_tool_calls.empty? || c.runaway?
213
+ c
214
+ } |
215
+ side_worker do |c|
216
+ unless c.finished?
217
+ c.pending_tool_calls.each do |tc|
218
+ puts " ⚙ #{tc.dig("function", "name")} #{tc.dig("function", "arguments")}"
219
+ end
220
+ end
221
+ end |
222
+ Lyman::Workers.tool_execution(handlers) |
223
+ side_worker { |c| rounds << c unless c.finished? } |
224
+ filter_worker { |c| c.finished? }
225
+
226
+ # ── Shell process ───────────────────────────────────────────────────────────
227
+ puts <<~PREAMBLE
228
+ lyman ⇢ #{MODEL} @ #{BASE_URL}
229
+
230
+ exit: blank line or ctrl-d
231
+ model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
232
+ endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
233
+ tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
234
+
235
+ PREAMBLE
236
+
237
+ loop do
238
+ print "\nyou> "
239
+ input = $stdin.gets&.strip
240
+ break if input.nil? || input.empty?
241
+
242
+ rounds << conversation.add_user_message(input)
243
+ pipeline.shift
244
+ end
@@ -0,0 +1,61 @@
1
+ module Lyman
2
+ module CLI
3
+ module Commands
4
+ # `lyman add ARTIFACT` — plant one artifact into an already-scaffolded
5
+ # project. Where `new` plants everything blind, `add` has to reconcile
6
+ # against whatever the manifest and filesystem already say, so most of
7
+ # this class is the branching the design doc calls out: managed/owned
8
+ # no-op, ejected tombstone (ask first), untracked file (refuse first).
9
+ class Add
10
+ def initialize(thor, source_root:)
11
+ @thor = thor
12
+ @source_root = source_root
13
+ end
14
+
15
+ def call(artifact, force: false)
16
+ project_root = Manifest.find!
17
+ manifest = Manifest.load(project_root)
18
+ name = Registry.resolve(artifact, manifest: manifest)
19
+ spec = Registry.fetch(name)
20
+ entry = manifest.artifact(name)
21
+ dest = File.join(project_root, spec[:dest])
22
+
23
+ case entry&.fetch("status", nil)
24
+ when "managed", "owned"
25
+ @thor.say "#{name} is already #{entry["status"]}; nothing to do."
26
+ return
27
+ when "ejected"
28
+ unless force || @thor.yes?("#{name} was ejected at #{entry["ejected_at"]}; re-adding replaces your fork with the current upstream version. Continue? (y/N)")
29
+ @thor.say "Left #{name} as-is."
30
+ return
31
+ end
32
+ else
33
+ if File.exist?(dest) && !force
34
+ raise Thor::Error, "#{dest} already exists and isn't tracked by lyman. " \
35
+ "Move it aside, or run `lyman add #{name} --force` to overwrite it."
36
+ end
37
+ end
38
+
39
+ plant(manifest, name, spec, project_root)
40
+ manifest.save
41
+ @thor.say "Planted #{name} (#{spec[:role]}) at #{spec[:dest]}."
42
+ end
43
+
44
+ private
45
+
46
+ def plant(manifest, name, spec, project_root)
47
+ bytes = Planter.plant(name, spec, project_root: project_root, source_root: @source_root)
48
+ manifest.write_pristine(spec[:dest], bytes)
49
+
50
+ attrs = {
51
+ "status" => spec[:role].to_s,
52
+ "planted_at" => Lyman::CLI::VERSION,
53
+ "path" => spec[:dest]
54
+ }
55
+ attrs["hash"] = Planter.hash(bytes) if spec[:role] == :managed
56
+ manifest.set_artifact(name, attrs)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,57 @@
1
+ require "tempfile"
2
+ require "open3"
3
+
4
+ module Lyman
5
+ module CLI
6
+ module Commands
7
+ # `lyman diff ARTIFACT` — shows "your changes" (pristine vs. working
8
+ # file) and "upstream changes since your copy was planted" (pristine
9
+ # vs. a freshly rendered copy), by shelling out to `diff -u`. The
10
+ # pristine-as-planted copy is the fork point in both comparisons, so
11
+ # the two sections never mix upstream's changes with the user's own.
12
+ class Diff
13
+ def initialize(thor, source_root:)
14
+ @thor = thor
15
+ @source_root = source_root
16
+ end
17
+
18
+ def call(artifact)
19
+ project_root = Manifest.find!
20
+ manifest = Manifest.load(project_root)
21
+ name = Registry.resolve(artifact, manifest: manifest)
22
+ spec = Registry.fetch(name)
23
+ entry = manifest.artifact(name)
24
+ path = entry && entry["path"]
25
+
26
+ unless path && manifest.pristine?(path)
27
+ raise Thor::Error, "#{name} has no pristine copy in this project; nothing to diff against."
28
+ end
29
+
30
+ pristine_path = manifest.pristine_path(path)
31
+ dest = File.join(project_root, path)
32
+
33
+ @thor.say "--- your changes (#{name}) ---"
34
+ @thor.say section(pristine_path, dest, "pristine/#{path}", path)
35
+
36
+ @thor.say ""
37
+ @thor.say "--- upstream changes since planted (#{name}) ---"
38
+ rendered = Planter.render(name, spec, source_root: @source_root)
39
+ Tempfile.create(name) do |upstream|
40
+ upstream.write(rendered)
41
+ upstream.flush
42
+ @thor.say section(pristine_path, upstream.path, "pristine/#{path}", "upstream/#{spec[:dest]}")
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # `diff` exits 1 when the compared files differ — that's the
49
+ # expected, successful case here, not a failure to raise on.
50
+ def section(from, to, from_label, to_label)
51
+ out, _status = Open3.capture2("diff", "-u", "-L", from_label, "-L", to_label, from, to)
52
+ out.empty? ? "(none)" : out
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end