lyman 0.1.0 → 0.2.1

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: 42c4c9146ed53714fa0725775e91ffcbc599e485d652e84d483dedca2f47e047
4
- data.tar.gz: e17d5dde1e338e8bae43cb97f5633b0cd4e24e06d66535bafdd2ff74319c9764
3
+ metadata.gz: 10c9bb49a3623d7ef8179f561101dca35a940a5efb510a05b349c527a1141764
4
+ data.tar.gz: 6fa324e06604573e06c658991618a4715968ac097c3fff616abf0ba4f20a3f22
5
5
  SHA512:
6
- metadata.gz: 48c20fb9bb06c5f685b47491cc3d96cf33e7835b7b44bc1723bd87feec82a75d334b923a38c5d5838595bba31697e958e150dad4a5305e113edb454bcfd53e17
7
- data.tar.gz: 26e9984e59ddc9722cb50bac1a3567cf9c424367c8dbed5e48bbf6b47fe3051052c59cd9e24a83fd5c7b49a81f056b2b256de89dda000c92798d536e072a8d5f
6
+ metadata.gz: db9944dbe1f9258b65e9e682aedaf41cd4cdcba895a68e90e64390fbe0dfebb81d335df410338c3dd35ebfbbae553b592d7c642139a83e2c0cbdbf9d279360ff
7
+ data.tar.gz: 8ccdb31708d12bdbdaef0ff9438148165d7bdfe15dd19bcbc26f86cd600964fc046e9589185fc62824b55be427deff93287524b2f6e799a898533d58873ce6d1
data/README.md CHANGED
@@ -152,9 +152,15 @@ Early and moving. What exists today: the vision, circuit-pattern, and
152
152
  deployment design docs, the core `Conversation` item, chat-completion and
153
153
  tool-execution workers, a working tool-using chat harness against live local
154
154
  models, and the generator CLI (`new` / `add` / `update` / `eject` / `diff` /
155
- `doctor` / `list`) with a Minitest suite behind it. Not yet published to
156
- rubygems.org — for now, run it from a checkout. On deck: publishing the gem,
157
- tool-call fan-out, and a one-shot (non-REPL) harness.
155
+ `doctor` / `list`) with a Minitest suite behind it. Published to
156
+ [rubygems.org](https://rubygems.org/gems/lyman)install it with:
157
+
158
+ ```sh
159
+ gem install lyman
160
+ lyman new my-agent
161
+ ```
162
+
163
+ On deck: tool-call fan-out and a one-shot (non-REPL) harness.
158
164
 
159
165
  ## License
160
166
 
@@ -150,7 +150,12 @@ Three channels, ordered by reach:
150
150
  `AGENTS.md`) as a first-class artifact carrying the load-bearing facts: the
151
151
  nil-source footgun, the runaway-turn guard, item-as-control discipline,
152
152
  wire-vs-conversation separation. Highest-value channel: works with any
153
- coding agent, zero install steps, travels with the code.
153
+ coding agent, zero install steps, travels with the code. For projects that
154
+ already have a `CLAUDE.md` lyman shouldn't clobber, the same guidance
155
+ ships as an opt-in Claude Code skill (`lyman add claude_skill` plants
156
+ `.claude/skills/lyman/SKILL.md`); `lyman add claude_md` points there when
157
+ it refuses to overwrite an existing file. Opt-in artifacts (`optional:` in
158
+ the registry) are skipped by `new` and reached with `add`.
154
159
  2. **A Claude plugin marketplace** in the `joelhelbling/lyman` repo, for
155
160
  richer *procedures* — e.g. a skill that knows how to wire a new tool worker
156
161
  correctly, or how to read a stalled pipeline. Skills earn their keep when
data/harness/chat.rb CHANGED
@@ -42,15 +42,40 @@ schemas = TOOLS.values.map { |tool| tool[:schema] }
42
42
  handlers = TOOLS.transform_values { |tool| tool[:handler] }
43
43
 
44
44
  # ── Display: fitting this harness to its models ─────────────────────────────
45
+ # Everything from here to "Shell state" is presentation. Its dependencies —
46
+ # cli-ui for color and glyphs, reline (stdlib) for prompt line-editing and
47
+ # history — are confined to this section; restyle or delete freely without
48
+ # touching the circuit.
49
+ require "cli/ui"
50
+ require "reline"
51
+
52
+ # Styling codes used across streamed fragments, where CLI::UI.fmt can't help
53
+ # (fmt resets at the end of each call; a think preview arrives in pieces).
54
+ # Empty when piped, matching cli-ui's own no-tty behavior.
55
+ TTY = $stdout.tty?
56
+ GRAY = TTY ? CLI::UI.resolve_color(:gray).code : ""
57
+ DIM = TTY ? "\e[2;3m" : "" # faint italic, for think previews
58
+ RESET = TTY ? CLI::UI::Color::RESET.code : ""
59
+
60
+ # Paint without CLI::UI.fmt so text we don't control (tool arguments and
61
+ # results) can't be misread as {{markup}}.
62
+ def gray(text) = "#{GRAY}#{text}#{RESET}"
45
63
 
46
64
  # Thinking models prefix replies with <think>...</think> (the worker
47
65
  # normalizes separate-reasoning-field servers to the same convention). When
48
66
  # 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.
67
+ # this streams a short preview of the thinking — the first few lines,
68
+ # rendered as a dim "✻ " aside rather than the literal tags — then elides
69
+ # the rest once the model stops thinking.
70
+ #
71
+ # Emits [think_text, reply_text] pairs. Both stream straight to the terminal
72
+ # today, but the split is the seam for reply-side processing (a markdown
73
+ # renderer, say — mind that anything buffering the reply trades away its
74
+ # token-by-token streaming, which is why this harness doesn't ship one).
51
75
  class ThinkFilter
52
76
  OPEN = "<think>"
53
77
  CLOSE = "</think>"
78
+ MARK = "✻ "
54
79
  SNIPPET_LINES = 3
55
80
  SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
56
81
 
@@ -62,25 +87,26 @@ class ThinkFilter
62
87
  @truncated = false
63
88
  end
64
89
 
65
- # Returns the printable portion of this fragment.
90
+ # Returns the printable portion of this fragment as a
91
+ # [think_text, reply_text] pair (either may be empty).
66
92
  def filter(fragment)
67
93
  @buffer << fragment
68
94
  case @state
69
95
  when :start then filter_start
70
96
  when :thinking then filter_thinking
71
- when :after_think then filter_after_think
72
- when :passing then take_buffer
97
+ when :after_think then ["", filter_after_think]
98
+ when :passing then ["", take_buffer]
73
99
  end
74
100
  end
75
101
 
76
102
  # 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.
103
+ # (e.g. a reply that was nothing but "<thin"), and closes out the styling
104
+ # of a think block the model never closed itself.
79
105
  def flush
80
106
  case @state
81
- when :start then take_buffer
82
- when :thinking then (@truncated ? "..." : "") + CLOSE
83
- else ""
107
+ when :start then ["", take_buffer]
108
+ when :thinking then [(@truncated ? "" : "") + RESET, ""]
109
+ else ["", ""]
84
110
  end
85
111
  end
86
112
 
@@ -96,12 +122,13 @@ class ThinkFilter
96
122
  if @buffer.start_with?(OPEN)
97
123
  @state = :thinking
98
124
  @buffer = @buffer[OPEN.length..]
99
- OPEN + filter_thinking
125
+ think, reply = filter_thinking
126
+ [DIM + MARK + think, reply]
100
127
  elsif OPEN.start_with?(@buffer)
101
- "" # could still become <think>; wait for more
128
+ ["", ""] # could still become <think>; wait for more
102
129
  else
103
130
  @state = :passing
104
- take_buffer
131
+ ["", take_buffer]
105
132
  end
106
133
  end
107
134
 
@@ -110,14 +137,14 @@ class ThinkFilter
110
137
  thought = @buffer[0...idx]
111
138
  @buffer = @buffer[(idx + CLOSE.length)..]
112
139
  @state = :after_think
113
- snippet(thought) + (@truncated ? "..." : "") + CLOSE + "\n\n" + filter_after_think
140
+ [snippet(thought) + (@truncated ? "" : "") + RESET + "\n\n", filter_after_think]
114
141
  else
115
142
  # Keep any tail that could be the start of CLOSE split across
116
143
  # fragments; preview the rest.
117
144
  keep = partial_close_suffix
118
145
  thought = @buffer[0, @buffer.length - keep.length]
119
146
  @buffer = keep
120
- snippet(thought)
147
+ [snippet(thought), ""]
121
148
  end
122
149
  end
123
150
 
@@ -137,8 +164,8 @@ class ThinkFilter
137
164
  out
138
165
  end
139
166
 
140
- # We print "</think>\n\n" ourselves, so swallow the model's own
141
- # whitespace between the close tag and the reply.
167
+ # We end the think preview with "\n\n" ourselves, so swallow the model's
168
+ # own whitespace between the close tag and the reply.
142
169
  def filter_after_think
143
170
  stripped = @buffer.lstrip
144
171
  if stripped.empty?
@@ -160,25 +187,59 @@ class ThinkFilter
160
187
  end
161
188
  end
162
189
 
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.
190
+ # The silence between sending a prompt and the first streamed token can be
191
+ # long on a local model (prefill). cli-ui's spinner owns the calling thread
192
+ # for the duration of a block, which can't express "spin until the first
193
+ # delta arrives" — so this is the one hand-rolled widget: a background
194
+ # spinner with a stop method. Quiet when output is piped.
195
+ class WaitSpinner
196
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
197
+
198
+ def start(label)
199
+ return unless TTY
200
+ stop
201
+ @thread = Thread.new do
202
+ FRAMES.cycle do |frame|
203
+ print "\r#{GRAY}#{frame} #{label}#{RESET}"
204
+ sleep 0.08
205
+ end
206
+ end
207
+ end
208
+
209
+ def stop
210
+ return unless @thread
211
+ @thread.kill.join
212
+ @thread = nil
213
+ print "\r\e[K" # erase the spinner line
214
+ end
215
+ end
216
+
217
+ # Streams one round's content to the terminal: a spinner while waiting on
218
+ # the model, then the model label before the first visible text — so silent
219
+ # rounds (pure tool calls) don't leave an empty prompt behind.
166
220
  class RoundPrinter
167
221
  def initialize(label)
168
222
  @label = label
223
+ @spinner = WaitSpinner.new
169
224
  end
170
225
 
171
226
  def start_round
172
- @filter = ThinkFilter.new
227
+ @think = ThinkFilter.new
173
228
  @printed = false
229
+ @spinner.start("waiting for #{@label}…")
174
230
  end
175
231
 
176
232
  def delta(text)
177
- emit(@filter.filter(text))
233
+ think, reply = @think.filter(text)
234
+ emit(think)
235
+ emit(reply)
178
236
  end
179
237
 
180
238
  def finish_round
181
- emit(@filter.flush)
239
+ @spinner.stop
240
+ think, reply = @think.flush
241
+ emit(think)
242
+ emit(reply)
182
243
  puts if @printed
183
244
  end
184
245
 
@@ -186,18 +247,56 @@ class RoundPrinter
186
247
 
187
248
  def emit(text)
188
249
  return if text.empty?
189
- print "\n#{@label}> " unless @printed
250
+ @spinner.stop
251
+ print CLI::UI.fmt("\n{{magenta:#{@label}}}> ") unless @printed
190
252
  @printed = true
191
253
  print text
192
254
  end
193
255
  end
194
256
 
257
+ # Prints tool activity around the execution stage: the calls the model
258
+ # requested on the way in, a ✓-and-result line on the way out. Results are
259
+ # summarized to one line here — the full text is on the conversation, where
260
+ # the model (and any logging side-worker) sees it.
261
+ class ToolPrinter
262
+ RESULT_WIDTH = 60
263
+
264
+ def calls(conversation)
265
+ conversation.pending_tool_calls.each do |tool_call|
266
+ puts gray(" ⚙ #{tool_call.dig("function", "name")} #{tool_call.dig("function", "arguments")}")
267
+ end
268
+ end
269
+
270
+ def results(conversation)
271
+ messages = conversation.messages
272
+ request = messages.rindex { |m| m["role"] == "assistant" }
273
+ return unless request
274
+ names = (messages[request]["tool_calls"] || [])
275
+ .to_h { |tc| [tc["id"], tc.dig("function", "name")] }
276
+ messages[(request + 1)..].each do |message|
277
+ next unless message["role"] == "tool"
278
+ summary = gray("#{names[message["tool_call_id"]]} → #{summarize(message["content"])}")
279
+ puts " #{CLI::UI.fmt("{{v}}")} #{summary}"
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ def summarize(text)
286
+ line = text.to_s.gsub(/\s+/, " ").strip
287
+ (line.length > RESULT_WIDTH) ? "#{line[0, RESULT_WIDTH - 1]}…" : line
288
+ end
289
+ end
290
+
195
291
  # ── Shell state ─────────────────────────────────────────────────────────────
196
292
  conversation = Lyman::Conversation.new(
197
- system_prompt: "You are a helpful assistant. Keep replies brief."
293
+ system_prompt: "You are a helpful assistant. Keep replies brief. " \
294
+ "Your replies are printed verbatim in a plain-text terminal: write prose, " \
295
+ "and avoid markdown and LaTeX markup."
198
296
  )
199
297
  rounds = [] # the circuit's queue — visible right here, not smuggled
200
298
  printer = RoundPrinter.new(MODEL)
299
+ tool_printer = ToolPrinter.new
201
300
 
202
301
  # ── The circuit ─────────────────────────────────────────────────────────────
203
302
  pipeline =
@@ -212,31 +311,36 @@ pipeline =
212
311
  c.finish! if c.pending_tool_calls.empty? || c.runaway?
213
312
  c
214
313
  } |
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 |
314
+ side_worker { |c| tool_printer.calls(c) unless c.finished? } |
222
315
  Lyman::Workers.tool_execution(handlers) |
316
+ side_worker { |c| tool_printer.results(c) unless c.finished? } |
223
317
  side_worker { |c| rounds << c unless c.finished? } |
224
318
  filter_worker { |c| c.finished? }
225
319
 
226
320
  # ── 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
321
+ puts CLI::UI.fmt("{{bold:lyman}} ⇢ {{magenta:#{MODEL}}} @ {{blue:#{BASE_URL}}}")
322
+ puts
323
+ puts gray(<<~HINTS.gsub(/^/, " "))
324
+ exit: blank line, ctrl-c, or ctrl-d
325
+ change the model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
326
+ change the endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
327
+ available tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
328
+ HINTS
236
329
 
237
330
  loop do
238
- print "\nyou> "
239
- input = $stdin.gets&.strip
331
+ puts
332
+ # Reline gives the prompt line editing and history, but emits screen-
333
+ # redraw escapes even when input is piped — so scripted runs get gets.
334
+ input = begin
335
+ if $stdin.tty?
336
+ Reline.readline(CLI::UI.fmt("{{cyan:you}}> "), true)&.strip
337
+ else
338
+ print "you> "
339
+ $stdin.gets&.strip
340
+ end
341
+ rescue Interrupt
342
+ nil # ctrl-c leaves like ctrl-d, not with a stack trace
343
+ end
240
344
  break if input.nil? || input.empty?
241
345
 
242
346
  rounds << conversation.add_user_message(input)
@@ -31,8 +31,13 @@ module Lyman
31
31
  end
32
32
  else
33
33
  if File.exist?(dest) && !force
34
- raise Thor::Error, "#{dest} already exists and isn't tracked by lyman. " \
34
+ message = "#{dest} already exists and isn't tracked by lyman. " \
35
35
  "Move it aside, or run `lyman add #{name} --force` to overwrite it."
36
+ if (alt = spec[:alternative])
37
+ message += " Or plant `lyman add #{alt}` instead, " \
38
+ "which leaves #{spec[:dest]} untouched."
39
+ end
40
+ raise Thor::Error, message
36
41
  end
37
42
  end
38
43
 
@@ -19,7 +19,7 @@ module Lyman
19
19
  FileUtils.mkdir_p(project_root)
20
20
 
21
21
  manifest = Manifest.load(project_root)
22
- Registry::ARTIFACTS.each do |artifact_name, spec|
22
+ Registry.default.each do |artifact_name, spec|
23
23
  plant(manifest, artifact_name, spec, project_root)
24
24
  end
25
25
  manifest.save
@@ -42,13 +42,26 @@ module Lyman
42
42
  source: "templates/CLAUDE.md",
43
43
  dest: "CLAUDE.md",
44
44
  role: :owned,
45
+ alternative: "claude_skill",
45
46
  description: "Guidance for coding agents working in this project"
46
47
  },
48
+ # The same guidance as claude_md, packaged as a Claude Code skill —
49
+ # for projects that already have a CLAUDE.md lyman shouldn't clobber.
50
+ # `optional:` keeps `new` from planting it (a fresh scaffold gets
51
+ # claude_md instead); `alternative:` on claude_md points here when
52
+ # `add` refuses to overwrite an existing CLAUDE.md.
53
+ "claude_skill" => {
54
+ source: "templates/SKILL.md",
55
+ dest: ".claude/skills/lyman/SKILL.md",
56
+ role: :owned,
57
+ optional: true,
58
+ description: "Claude Code skill variant of the CLAUDE.md guidance — for projects with their own CLAUDE.md"
59
+ },
47
60
  "gemfile" => {
48
61
  source: "templates/Gemfile",
49
62
  dest: "Gemfile",
50
63
  role: :owned,
51
- description: "Client dependencies: shifty (and ostruct for ruby >= 4)"
64
+ description: "Client dependencies: shifty (plus ostruct for ruby >= 4, cli-ui for the harness display)"
52
65
  },
53
66
  "gitignore" => {
54
67
  source: "templates/gitignore",
@@ -90,6 +103,13 @@ module Lyman
90
103
  ARTIFACTS.select { |_, spec| spec[:role] == :managed }
91
104
  end
92
105
 
106
+ # What `new` plants: everything except opt-in artifacts (those exist
107
+ # for situations a fresh scaffold can't be in, like a pre-existing
108
+ # CLAUDE.md — reach them with `lyman add`).
109
+ def self.default
110
+ ARTIFACTS.reject { |_, spec| spec[:optional] }
111
+ end
112
+
93
113
  def self.source_path(spec, source_root: GEM_ROOT)
94
114
  File.join(source_root, spec[:source])
95
115
  end
@@ -2,6 +2,6 @@ module Lyman
2
2
  module CLI
3
3
  # The gem's own version — distinct from any artifact's `planted_at`.
4
4
  # This is what the manifest's top-level `lyman:` key records.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/lyman/cli.rb CHANGED
@@ -80,7 +80,8 @@ module Lyman
80
80
  entry ? entry["status"] : "not planted"
81
81
  end
82
82
 
83
- line = "#{name} (#{spec[:role]}) #{spec[:dest]}#{" — #{status}" if status} — #{spec[:description]}"
83
+ role = "#{spec[:role]}#{", opt-in" if spec[:optional]}"
84
+ line = "#{name} (#{role}) #{spec[:dest]}#{" — #{status}" if status} — #{spec[:description]}"
84
85
  say line
85
86
  end
86
87
  end
data/templates/CLAUDE.md CHANGED
@@ -7,8 +7,9 @@ against any OpenAI-compatible chat completions endpoint (Ollama by default).
7
7
 
8
8
  ## Running it
9
9
 
10
- - `bundle install` — install dependencies (just `shifty` and `ostruct`; this
11
- project has no runtime dependency on the `lyman` gem itself).
10
+ - `bundle install` — install dependencies (`shifty`, `ostruct`, and `cli-ui`
11
+ for the harness's display layer; this project has no runtime dependency on
12
+ the `lyman` gem itself).
12
13
  - `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
13
14
  Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
14
15
  `LYMAN_BASE_URL` env vars.
data/templates/Gemfile CHANGED
@@ -2,3 +2,6 @@ source "https://rubygems.org"
2
2
 
3
3
  gem "shifty"
4
4
  gem "ostruct" # shifty dependency; no longer a default gem as of ruby 4.0
5
+ # Used only by harness/chat.rb's display layer; drop them if you restyle.
6
+ gem "cli-ui"
7
+ gem "reline"
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: lyman
3
+ description: Guidance for working in a lyman-scaffolded agentic harness — the pipeline-of-workers model, the managed/owned boundary, and lyman's sharp edges. Use when reading or changing the harness (harness/chat.rb), anything under lib/lyman/, or when running lyman CLI commands (add, update, eject, diff, doctor).
4
+ ---
5
+
6
+ # Working in a lyman project
7
+
8
+ This project was scaffolded by [lyman](https://github.com/joelhelbling/lyman):
9
+ an agentic harness built on the [shifty](https://github.com/joelhelbling/shifty)
10
+ gem's pipeline-of-workers model. `harness/chat.rb` wires a model⇄tool loop
11
+ against any OpenAI-compatible chat completions endpoint (Ollama by default).
12
+
13
+ ## Running it
14
+
15
+ - `bundle install` — install dependencies (just `shifty` and `ostruct`; this
16
+ project has no runtime dependency on the `lyman` gem itself).
17
+ - `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
18
+ Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
19
+ `LYMAN_BASE_URL` env vars.
20
+
21
+ ## The managed/owned boundary
22
+
23
+ Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
24
+ **managed** by the lyman CLI: it was planted here, and `lyman update` can
25
+ refresh it from newer lyman releases. Treat it as a library to extend, not a
26
+ file to hand-edit — if you need to change one, run `lyman eject <name>` first,
27
+ which takes ownership explicitly rather than leaving it silently forked.
28
+ `.lyman/manifest.yml` is the record of what's managed, what's owned, and what
29
+ you've ejected; commit it.
30
+
31
+ `harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
32
+ day one, never touched by `lyman update`. Put your own workers in your own
33
+ namespace or directory, not inside `lib/lyman/`.
34
+
35
+ ## Four load-bearing facts
36
+
37
+ 1. **The nil-source footgun.** In shifty, a source returning `nil` ends the
38
+ stream permanently. If you're driving a pipeline off a queue, enqueue
39
+ before you shift — never pull a source worker while its queue is empty.
40
+
41
+ 2. **The runaway-turn guard.** The model⇄tool circuit is bounded by
42
+ `Conversation#runaway?` / `max_rounds`. Keep that guard intact when
43
+ rewiring the circuit; without it, a model that keeps calling tools never
44
+ lets a turn end.
45
+
46
+ 3. **Item-as-control discipline.** An item may tell a worker *whether* to
47
+ act, never *which of several things* to do. A worker that switches
48
+ between jobs based on item state is the anti-pattern to avoid
49
+ (multi-way dispatch) — split it into stages, or use a splitter, instead.
50
+
51
+ 4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
52
+ the `Conversation` for observability, but `Workers.wire_messages` strips
53
+ it before anything goes out over the wire. Preserve that separation if
54
+ you touch message handling.
55
+
56
+ ## Other conventions
57
+
58
+ - `lyman doctor` smoke-tests the pipeline end to end against a stub
59
+ transport — run it after `lyman update` or whenever something feels off.
60
+ - `lyman list` shows what's managed, owned, or ejected in this project.
61
+ - Messages use string keys throughout, matching the OpenAI-compatible wire
62
+ format — no symbol-key message hashes.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lyman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Helbling
@@ -83,6 +83,7 @@ files:
83
83
  - lib/lyman/workers/tool_execution.rb
84
84
  - templates/CLAUDE.md
85
85
  - templates/Gemfile
86
+ - templates/SKILL.md
86
87
  - templates/gitignore
87
88
  - templates/lib_lyman.rb
88
89
  homepage: https://github.com/joelhelbling/lyman
@@ -104,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
105
  - !ruby/object:Gem::Version
105
106
  version: '0'
106
107
  requirements: []
107
- rubygems_version: 4.0.10
108
+ rubygems_version: 4.0.15
108
109
  specification_version: 4
109
110
  summary: A composable agentic harness, delivered as code you own — a pure generator
110
111
  in the shadcn/ui mold, built on shifty