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 +4 -4
- data/README.md +9 -3
- data/docs/design/deployment.md +6 -1
- data/harness/chat.rb +147 -43
- data/lib/lyman/cli/commands/add.rb +6 -1
- data/lib/lyman/cli/commands/new.rb +1 -1
- data/lib/lyman/cli/registry.rb +21 -1
- data/lib/lyman/cli/version.rb +1 -1
- data/lib/lyman/cli.rb +2 -1
- data/templates/CLAUDE.md +3 -2
- data/templates/Gemfile +3 -0
- data/templates/SKILL.md +62 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10c9bb49a3623d7ef8179f561101dca35a940a5efb510a05b349c527a1141764
|
|
4
|
+
data.tar.gz: 6fa324e06604573e06c658991618a4715968ac097c3fff616abf0ba4f20a3f22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
156
|
-
rubygems.org —
|
|
157
|
-
|
|
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
|
|
data/docs/design/deployment.md
CHANGED
|
@@ -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
|
|
50
|
-
#
|
|
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
|
|
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 ? "
|
|
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
|
-
|
|
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 ? "
|
|
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
|
|
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
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
233
|
+
think, reply = @think.filter(text)
|
|
234
|
+
emit(think)
|
|
235
|
+
emit(reply)
|
|
178
236
|
end
|
|
179
237
|
|
|
180
238
|
def finish_round
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
22
|
+
Registry.default.each do |artifact_name, spec|
|
|
23
23
|
plant(manifest, artifact_name, spec, project_root)
|
|
24
24
|
end
|
|
25
25
|
manifest.save
|
data/lib/lyman/cli/registry.rb
CHANGED
|
@@ -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 (
|
|
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
|
data/lib/lyman/cli/version.rb
CHANGED
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
|
-
|
|
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 (
|
|
11
|
-
project has no runtime dependency on
|
|
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
data/templates/SKILL.md
ADDED
|
@@ -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
|
|
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.
|
|
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
|