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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/docs/design/circuit-pattern.md +167 -0
- data/docs/design/deployment.md +195 -0
- data/docs/vision.md +230 -0
- data/exe/lyman +4 -0
- data/harness/chat.rb +244 -0
- data/lib/lyman/cli/commands/add.rb +61 -0
- data/lib/lyman/cli/commands/diff.rb +57 -0
- data/lib/lyman/cli/commands/doctor.rb +122 -0
- data/lib/lyman/cli/commands/eject.rb +53 -0
- data/lib/lyman/cli/commands/new.rb +72 -0
- data/lib/lyman/cli/commands/update.rb +154 -0
- data/lib/lyman/cli/doctor_check.rb +97 -0
- data/lib/lyman/cli/manifest.rb +113 -0
- data/lib/lyman/cli/planter.rb +40 -0
- data/lib/lyman/cli/registry.rb +98 -0
- data/lib/lyman/cli/version.rb +7 -0
- data/lib/lyman/cli.rb +100 -0
- data/lib/lyman/conversation.rb +65 -0
- data/lib/lyman/workers/chat_completion.rb +154 -0
- data/lib/lyman/workers/tool_execution.rb +47 -0
- data/lib/lyman.rb +8 -0
- data/templates/CLAUDE.md +57 -0
- data/templates/Gemfile +4 -0
- data/templates/gitignore +5 -0
- data/templates/lib_lyman.rb +8 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 42c4c9146ed53714fa0725775e91ffcbc599e485d652e84d483dedca2f47e047
|
|
4
|
+
data.tar.gz: e17d5dde1e338e8bae43cb97f5633b0cd4e24e06d66535bafdd2ff74319c9764
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48c20fb9bb06c5f685b47491cc3d96cf33e7835b7b44bc1723bd87feec82a75d334b923a38c5d5838595bba31697e958e150dad4a5305e113edb454bcfd53e17
|
|
7
|
+
data.tar.gz: 26e9984e59ddc9722cb50bac1a3567cf9c424367c8dbed5e48bbf6b47fe3051052c59cd9e24a83fd5c7b49a81f056b2b256de89dda000c92798d536e072a8d5f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joel Helbling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Lyman
|
|
2
|
+
|
|
3
|
+
**A composable agentic harness — and a framework for building harnesses — in
|
|
4
|
+
Ruby, built on [shifty](https://github.com/joelhelbling/shifty).**
|
|
5
|
+
|
|
6
|
+
Lyman is for people building agentic workflows on models they run themselves:
|
|
7
|
+
open-weight models on Ollama, LM Studio, vLLM, llama.cpp — or models they've
|
|
8
|
+
trained. If that's you, you start with less data, less tooling maturity, and
|
|
9
|
+
less margin for error than the frontier labs. Your one real edge is
|
|
10
|
+
**iteration speed**. Lyman exists to maximize it.
|
|
11
|
+
|
|
12
|
+
> *Lyman*, as in the Lyman series — a nod to the physicist, and to
|
|
13
|
+
> **redshift**, which is in turn a nod to **Ruby** and the **shifty** gem. A
|
|
14
|
+
> series is also, fittingly, a sequence of discrete lines — like the stages of
|
|
15
|
+
> a pipeline.
|
|
16
|
+
|
|
17
|
+
## The idea
|
|
18
|
+
|
|
19
|
+
An agent harness is a pipeline. Not as a metaphor — literally:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
pipeline =
|
|
23
|
+
source_worker { rounds.shift } |
|
|
24
|
+
Lyman::Workers.chat_completion(base_url:, model:, tools:) |
|
|
25
|
+
relay_worker { |c| c.finish! if c.pending_tool_calls.empty?; c } |
|
|
26
|
+
Lyman::Workers.tool_execution(handlers) |
|
|
27
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
28
|
+
filter_worker { |c| c.finished? }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That's not pseudocode — it's the working heart of [`harness/chat.rb`](harness/chat.rb),
|
|
32
|
+
a tool-using chat agent for local models. Every stage is a plain
|
|
33
|
+
[shifty](https://github.com/joelhelbling/shifty) worker. Want logging? Splice
|
|
34
|
+
in a `side_worker` — anywhere. Want a guardrail? A `filter_worker`. Want to
|
|
35
|
+
swap Ollama for vLLM, or native tool-calling for prompt-and-parse emulation on
|
|
36
|
+
a model that lacks it? Replace one worker; nothing else changes. There are
|
|
37
|
+
**no hooks**, because there's nothing to hook *into* — the whole loop is
|
|
38
|
+
already open.
|
|
39
|
+
|
|
40
|
+
Two principles drive every design decision:
|
|
41
|
+
|
|
42
|
+
1. **Legibility.** One paradigm — a pipeline of workers — applied everywhere,
|
|
43
|
+
at every level.
|
|
44
|
+
2. **Guts on the outside.** Nothing important happens anywhere you can't see,
|
|
45
|
+
name, or splice into. This is deliberately the opposite of the frontier-SDK
|
|
46
|
+
ethos, where the agent loop is a black box you configure from the edges.
|
|
47
|
+
|
|
48
|
+
And a razor for when they're in tension with convenience: **favor whatever
|
|
49
|
+
lets a user see and change behavior faster.** For a data-poor tinkerer,
|
|
50
|
+
transparency isn't an aesthetic — it's what makes fast iteration possible.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
You'll need Ruby (see [`mise.toml`](mise.toml)) and a local model server
|
|
55
|
+
speaking the OpenAI-compatible API — [Ollama](https://ollama.com) and
|
|
56
|
+
[LM Studio](https://lmstudio.ai) both work. Use a model with native tool
|
|
57
|
+
support (e.g. `qwen3.5`, `gemma4`, `mistral`).
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
bundle install
|
|
61
|
+
bundle exec ruby harness/chat.rb
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
lyman ⇢ gemma4:latest @ http://localhost:11434/v1
|
|
66
|
+
|
|
67
|
+
you> what time is it?
|
|
68
|
+
⚙ current_time {}
|
|
69
|
+
|
|
70
|
+
gemma4:latest> It's 9:25 PM on Friday, July 3rd.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
That `⚙` line is a side worker showing you the model calling a tool — and the
|
|
74
|
+
reply after it means two model round-trips completed inside a single pull on
|
|
75
|
+
the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
|
|
76
|
+
|
|
77
|
+
## How it works, briefly
|
|
78
|
+
|
|
79
|
+
- **The item is the conversation.** What flows through the pipeline is a
|
|
80
|
+
`Conversation` — the full message history plus the control data workers
|
|
81
|
+
consult (`finished?`, `pending_tool_calls`). A turn carries its whole
|
|
82
|
+
conversation with it; that's what makes it a conversation.
|
|
83
|
+
- **State lives in the shell.** The enclosing scope — the *shell* — is just
|
|
84
|
+
state plus a process: the conversation, a queue, and a deliberately boring
|
|
85
|
+
loop. A human REPL is one shell; an autonomous email-triager is another.
|
|
86
|
+
Lyman is turn-shaped, not chat-shaped.
|
|
87
|
+
- **The agentic loop is a circuit, not a special construct.** Shifty pipelines
|
|
88
|
+
are strictly linear, so the model⇄tool cycle is built from stock parts: a
|
|
89
|
+
source reading from a queue, a side worker re-enqueueing unfinished rounds,
|
|
90
|
+
and a filter at the tail that only lets finished turns escape. Demand does
|
|
91
|
+
the rest — N model⇄tool rounds happen inside one `shift`, with no loop
|
|
92
|
+
written anywhere. Full reasoning in
|
|
93
|
+
[docs/design/circuit-pattern.md](docs/design/circuit-pattern.md).
|
|
94
|
+
- **Dependencies stay inside the workers that need them.** The HTTP client
|
|
95
|
+
lives in exactly one file. Use your favorite gems — in such a way that only
|
|
96
|
+
the worker requiring one knows it exists.
|
|
97
|
+
|
|
98
|
+
The longer story — mission, principles, architecture decisions, open
|
|
99
|
+
questions — is in [docs/vision.md](docs/vision.md).
|
|
100
|
+
|
|
101
|
+
## Is lyman a good fit for you?
|
|
102
|
+
|
|
103
|
+
**Probably yes, if:**
|
|
104
|
+
|
|
105
|
+
- You're building a **narrow, purpose-built agent** — an email triager, a log
|
|
106
|
+
summarizer, a domain-specific assistant — tailored to specific problems and
|
|
107
|
+
specific models, and you want to iterate on it *fast*.
|
|
108
|
+
- You run models **locally or self-hosted** and want to build a harness that
|
|
109
|
+
treats that as the primary case, not an afterthought.
|
|
110
|
+
- You want to **understand and modify your whole agent loop**, not configure
|
|
111
|
+
the edges of someone else's.
|
|
112
|
+
- You like Ruby, or at least like the idea of an agent harness you can read
|
|
113
|
+
top to bottom in a single coding session.
|
|
114
|
+
|
|
115
|
+
**Probably not, if:**
|
|
116
|
+
|
|
117
|
+
- You want a no-code / low-code agent builder. Lyman is a developer's tool.
|
|
118
|
+
- You want feature parity with frontier-model harnesses and popular coding
|
|
119
|
+
agents. Lyman isn't chasing checklists — it's chasing the shortest path
|
|
120
|
+
from "what if the workflow did *this*?" to finding out.
|
|
121
|
+
- You want a batteries-included platform with every integration bundled.
|
|
122
|
+
Lyman's bet is that shifty makes those things easy enough to build yourself
|
|
123
|
+
that shipping them all would be beside the point.
|
|
124
|
+
|
|
125
|
+
## The generator
|
|
126
|
+
|
|
127
|
+
Lyman is delivered as a **pure generator**, in the spirit of shadcn/ui: the
|
|
128
|
+
gem's whole job is to plant legible, manifest-tracked modules into *your*
|
|
129
|
+
project, individually upgradeable and yours to extend — or eject and adopt
|
|
130
|
+
outright. No runtime framework to call into; your harness's only runtime
|
|
131
|
+
dependency is shifty. "Guts on the outside" extends to whose tree the code
|
|
132
|
+
lives in. The reasoning is written down in
|
|
133
|
+
[docs/design/deployment.md](docs/design/deployment.md).
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
lyman new my-agent # scaffold a project: harness, workers, manifest
|
|
137
|
+
lyman list # what lyman installs, and this project's status
|
|
138
|
+
lyman update # refresh pristine modules; halt (never clobber) on modified ones
|
|
139
|
+
lyman diff conversation # your changes, and upstream's since you planted
|
|
140
|
+
lyman eject conversation# take ownership; lyman stops managing it
|
|
141
|
+
lyman doctor # smoke-test the pipeline against a stub transport
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`.lyman/manifest.yml` records what was planted, at which version, with which
|
|
145
|
+
content hash — so `update` knows pristine from modified, ejected modules get
|
|
146
|
+
upstream-change advisories instead of merges, and your harness script is never
|
|
147
|
+
touched at all: it's yours from day one.
|
|
148
|
+
|
|
149
|
+
## Status
|
|
150
|
+
|
|
151
|
+
Early and moving. What exists today: the vision, circuit-pattern, and
|
|
152
|
+
deployment design docs, the core `Conversation` item, chat-completion and
|
|
153
|
+
tool-execution workers, a working tool-using chat harness against live local
|
|
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.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Design note: cycles in a linear pipeline — the circuit pattern
|
|
2
|
+
|
|
3
|
+
**Status:** accepted direction (pre-implementation)
|
|
4
|
+
**Resolves:** open question #1 in [../vision.md](../vision.md)
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
The agentic core of a conversation turn is a cycle: assemble request → call
|
|
9
|
+
model → *if the model called tools* → execute tools → append results → call
|
|
10
|
+
model again → … until the model answers without tool calls (or a guard trips).
|
|
11
|
+
The number of round trips is unknown at wire-up time; the model decides it at
|
|
12
|
+
runtime, per turn.
|
|
13
|
+
|
|
14
|
+
Shifty, however, is a strictly **linear, demand-driven pull chain**:
|
|
15
|
+
|
|
16
|
+
- Every worker has exactly one `supply`. `pipeline.shift` asks the *last*
|
|
17
|
+
worker for a value, which pulls from its supply, and so on up to the source.
|
|
18
|
+
Data flows down only because demand flows up.
|
|
19
|
+
- `Gang` is a list; composition is concatenation. There is no fan-in, no
|
|
20
|
+
fan-out, and no back-edge.
|
|
21
|
+
|
|
22
|
+
A naive back-edge (wiring the tool executor's output in as the model caller's
|
|
23
|
+
supply) cannot work: in a pull model, a topological cycle isn't a loop — it's
|
|
24
|
+
demand chasing its own tail. Shifty's linearity is load-bearing.
|
|
25
|
+
|
|
26
|
+
The values tension: the model⇄tool cycle is *the workshop* — where most
|
|
27
|
+
tinkering happens. "Guts on the outside" demands the loop be visible and
|
|
28
|
+
splice-able, but shifty's natural escape hatch (a `while` loop hidden inside
|
|
29
|
+
one fat worker's closure) buries exactly the thing we most want exposed.
|
|
30
|
+
|
|
31
|
+
## Shapes considered
|
|
32
|
+
|
|
33
|
+
| Shape | Verdict |
|
|
34
|
+
|---|---|
|
|
35
|
+
| **A. Fat worker** — one worker with an internal `while` loop doing model calls and tool dispatch | Rejected. The entire workshop becomes one opaque node; no splice points. Fails the design razor hard. |
|
|
36
|
+
| **B. Queue-mediated pseudo-cycle** — linear topology; a source reads from a queue; a tail worker re-enqueues unfinished items | Rejected *as originally conceived* (queue hidden inside the pipeline) — but see below: with the queue moved into the shell's scope, this objection dissolves. |
|
|
37
|
+
| **C. Unrolled N passes** — wire model→tool→model… a fixed K times | Rejected. K is a lie; the runtime iteration count is the model's decision. |
|
|
38
|
+
| **D. Loop lives in the enclosing scope** — linear inner pipeline; a small loop in coordinating scope re-feeds rounds | Right control-location instinct, but as first sketched it implied a new primitive. |
|
|
39
|
+
|
|
40
|
+
**The resolution is B + D combined:** the queue-mediated shape, with the queue
|
|
41
|
+
living in the shell's enclosing scope — declared in the one legible wiring
|
|
42
|
+
script, right next to the conversation state. Not smuggled; on the table.
|
|
43
|
+
|
|
44
|
+
## The circuit pattern
|
|
45
|
+
|
|
46
|
+
Stock shifty parts only. Illustrative pseudocode — **not committed API**:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
rounds = [] # the queue: shell scope, visible in the wiring script
|
|
50
|
+
|
|
51
|
+
inner = source_worker { rounds.shift } \
|
|
52
|
+
| relay_worker { |c| call_model(c) } \
|
|
53
|
+
| splitter_worker { |c| c.pending_tool_calls.any? ? c.split_tool_calls : [c] } \
|
|
54
|
+
| relay_worker { |c| c.tool_call? ? execute(c) : c } \
|
|
55
|
+
| batch_worker { |c, batch| c.round_complete?(batch) } \
|
|
56
|
+
| relay_worker { |batch| merge_round(batch) } \
|
|
57
|
+
| side_worker { |c| rounds << c unless c.finished? } \
|
|
58
|
+
| filter_worker { |c| c.finished? }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**The loop needs no loop.** When the shell calls `inner.shift`, the tail
|
|
62
|
+
`filter_worker` pulls until it receives a *finished* turn. Each time an
|
|
63
|
+
unfinished round reaches the tail, the `side_worker` has already re-enqueued
|
|
64
|
+
it; the filter rejects it and pulls again; demand propagates back up to the
|
|
65
|
+
source, which finds the re-enqueued item waiting. **N model⇄tool rounds happen
|
|
66
|
+
inside a single `shift` call, driven entirely by shifty's own demand
|
|
67
|
+
semantics.**
|
|
68
|
+
|
|
69
|
+
Role assignments:
|
|
70
|
+
|
|
71
|
+
- **`source_worker`** — reads from the queue; the only worker that knows where
|
|
72
|
+
items originate.
|
|
73
|
+
- **`splitter_worker` / `batch_worker`** — tool-call fan-out/fan-in: one model
|
|
74
|
+
response with three tool calls → three items → three executions → gathered
|
|
75
|
+
back into a round.
|
|
76
|
+
- **`side_worker`** — the back-edge: re-enqueues unfinished rounds.
|
|
77
|
+
- **`filter_worker`** — the loop condition: only finished turns escape.
|
|
78
|
+
|
|
79
|
+
Every stage is an ordinary, visible, spliceable worker.
|
|
80
|
+
|
|
81
|
+
### No third primitive
|
|
82
|
+
|
|
83
|
+
The cycle is a **pattern, not a primitive**. Shifty keeps exactly `Worker` and
|
|
84
|
+
`Gang`; lyman ships the circuit as a documented, scaffolded pattern made of
|
|
85
|
+
stock parts. This is "keeping shifty shifty" in the strongest sense.
|
|
86
|
+
|
|
87
|
+
## The shell
|
|
88
|
+
|
|
89
|
+
The enclosing scope is called the **shell** (formerly "conductor" in early
|
|
90
|
+
discussion). A shell is **state + a process** — an environment holding the
|
|
91
|
+
conversation and the queue, plus a driving process that is usually just a
|
|
92
|
+
`while` loop (or no loop at all, for a one-shot agent that takes a single
|
|
93
|
+
input, runs, and terminates).
|
|
94
|
+
|
|
95
|
+
The shell is *deliberately boring*. Other than setting up state, it does
|
|
96
|
+
nothing interesting — and that is a feature. If a shell is getting
|
|
97
|
+
interesting, something in it probably belongs in a worker.
|
|
98
|
+
|
|
99
|
+
Whether repetition happens via an unsatisfied filter at the tail or via
|
|
100
|
+
`while pipe.shift do ... end` in the shell, the shell remains just environment
|
|
101
|
+
plus process. A human REPL is one shell; an autonomous email triager is
|
|
102
|
+
another; same architecture, different shells.
|
|
103
|
+
|
|
104
|
+
**A TUI is not a shell.** A TUI is much more interesting than a shell, and
|
|
105
|
+
should run in parallel with the pipeline (so it isn't blocked while the
|
|
106
|
+
pipeline works). This keeps UI concerns from colonizing the shell.
|
|
107
|
+
|
|
108
|
+
## Item-as-control, and its discipline
|
|
109
|
+
|
|
110
|
+
The conversation item both *is mutated by* workers and *directs their
|
|
111
|
+
behavior* (e.g. `finished?`, `tool_call?`, pending tool calls). This is the
|
|
112
|
+
mechanism that keeps the pattern in stock shifty — but it is a spectrum with a
|
|
113
|
+
poisoned end:
|
|
114
|
+
|
|
115
|
+
- **Pass-through-if-not-mine** — a worker acts only when the item is in its
|
|
116
|
+
jurisdiction, else hands it along unchanged. Fine; same move
|
|
117
|
+
`filter_worker` already makes. The topology still tells the truth.
|
|
118
|
+
- **Multi-way dispatch** — a worker that inspects item state and does one of
|
|
119
|
+
several different jobs. A hidden pipeline folded into one worker; the
|
|
120
|
+
diagram lies. **Named anti-pattern.**
|
|
121
|
+
|
|
122
|
+
**The discipline:** *an item may tell a worker **whether** to act, never
|
|
123
|
+
**which of several things** to do. If you're switching, you're missing a stage
|
|
124
|
+
(or a splitter).*
|
|
125
|
+
|
|
126
|
+
## Control granularity: filter-in by default
|
|
127
|
+
|
|
128
|
+
- **Filter-in** (as sketched): the shell's `shift` returns once per *turn*;
|
|
129
|
+
all rounds happen inside the call.
|
|
130
|
+
- **Filter-out**: drop the tail filter; `shift` returns once per *round*, and
|
|
131
|
+
the shell does its own re-enqueueing.
|
|
132
|
+
|
|
133
|
+
Observability is a non-question either way — logs, TUI updates, and traces are
|
|
134
|
+
side workers, spliceable anywhere, and the shell can always inspect the queue
|
|
135
|
+
in its own scope. The real difference is **intervention**: with filter-out,
|
|
136
|
+
the shell can act between rounds (e.g. human approval of tool calls).
|
|
137
|
+
|
|
138
|
+
But intervention doesn't require control to return to the shell. A worker
|
|
139
|
+
*closes over the shell's scope* (the shifty README's "wormhole effect"), so an
|
|
140
|
+
approval gate can be a side worker that calls out to whatever collaborator the
|
|
141
|
+
shell scope provides, blocking until answered.
|
|
142
|
+
|
|
143
|
+
**Scaffold default: filter-in.** The turn is the natural unit at the spine;
|
|
144
|
+
rounds are the circuit's internal rhythm. Round-granularity access is a
|
|
145
|
+
legitimate *rewiring* — same parts, different wiring — which is the
|
|
146
|
+
kit-of-parts promise kept.
|
|
147
|
+
|
|
148
|
+
## Known sharp edges (must be handled in scaffold + docs)
|
|
149
|
+
|
|
150
|
+
1. **The nil footgun.** In shifty, a source returning `nil` means *end of
|
|
151
|
+
stream, forever*. An empty queue naturally returns `nil` from
|
|
152
|
+
`rounds.shift` — so a shell that pulls at the wrong moment doesn't get an
|
|
153
|
+
error; it permanently kills the pipeline. The scaffold must handle this
|
|
154
|
+
deliberately (shell only shifts after enqueueing, or the source
|
|
155
|
+
blocks/guards), and the docs must explain why.
|
|
156
|
+
2. **Runaway turns.** A model that never stops calling tools would cycle
|
|
157
|
+
forever inside one `shift`. Guard: the item carries a round counter; a
|
|
158
|
+
small guard worker marks the turn finished-with-error past a limit.
|
|
159
|
+
Control data riding on the item — consistent with the discipline above.
|
|
160
|
+
|
|
161
|
+
## What the visualization shows
|
|
162
|
+
|
|
163
|
+
The circuit is depicted as a single node annotated with its loop condition
|
|
164
|
+
("loops until: no tool calls"); drilling in reveals a plain linear pipeline
|
|
165
|
+
with a marked back-edge (the re-enqueueing side worker) and exit (the filter).
|
|
166
|
+
A loop *annotation* on a node can be depicted honestly; an actual graph cycle
|
|
167
|
+
in a pull model cannot.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Design note: deployment — lyman as a pure generator
|
|
2
|
+
|
|
3
|
+
**Status:** accepted direction (pre-implementation)
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
Lyman scaffolds *other projects*; it is not itself the thing users run. That
|
|
8
|
+
makes it rails-shaped at first glance: a globally installed gem used to create
|
|
9
|
+
and embellish client projects. But the rails analogy bundles two roles that
|
|
10
|
+
don't have to travel together:
|
|
11
|
+
|
|
12
|
+
1. **Generator** — `rails new`, the scaffolding tool.
|
|
13
|
+
2. **Runtime dependency** — the framework the app calls into forever.
|
|
14
|
+
|
|
15
|
+
The second role is in tension with lyman's core value. If
|
|
16
|
+
`Lyman::Workers.chat_completion` lives inside a gem, its guts are back inside
|
|
17
|
+
a box: users can read the source, but they can't casually splice into it the
|
|
18
|
+
way they can with code sitting in their own tree. "Guts on the outside" is a
|
|
19
|
+
claim about *whose tree the code lives in*, not just whether the source is
|
|
20
|
+
public.
|
|
21
|
+
|
|
22
|
+
## Shapes considered
|
|
23
|
+
|
|
24
|
+
| Shape | Verdict |
|
|
25
|
+
|---|---|
|
|
26
|
+
| **A. Conventional framework gem** — gem is both generator and runtime; client projects `require "lyman"` forever | Rejected. Every worker that lives in the gem is a seam that got harder to splice. The transparency promise erodes one convenience at a time. |
|
|
27
|
+
| **B. Template repository** — clone/fork a starter repo, no gem at all | Rejected. No upgrade story, no incremental `add`, no tooling seam for guidance. Fine for a demo; not a deployment model. |
|
|
28
|
+
| **C. Pure generator (the shadcn/ui model)** — the gem is a CLI that copies legible source into the client project, which the user then owns | **Accepted.** The framework is delivered as code you own — the deployment-shaped version of the design razor. |
|
|
29
|
+
|
|
30
|
+
Under shape C, the client project's only runtime dependency is `shifty`. There
|
|
31
|
+
is little or no `require "lyman"` at runtime; the lyman gem exists to plant,
|
|
32
|
+
diff, and update artifacts.
|
|
33
|
+
|
|
34
|
+
## The unit of upgrade is the unit of extraction
|
|
35
|
+
|
|
36
|
+
Copy-in's known trade-off is that copied code can't be `bundle update`d. The
|
|
37
|
+
answer is granularity: lyman plucks *modules* from its own codebase and plants
|
|
38
|
+
them as individual files, each tracked and upgraded independently.
|
|
39
|
+
|
|
40
|
+
This creates a forcing function that happens to point the same direction as an
|
|
41
|
+
existing principle. "The shell stays boring" was already the rule; now it has
|
|
42
|
+
teeth:
|
|
43
|
+
|
|
44
|
+
> Anything living inside the harness script is frozen the moment the user
|
|
45
|
+
> edits that script — and the user editing the harness is the *expected case*;
|
|
46
|
+
> it's their wiring diagram. Every behavior with any chance of shipping an
|
|
47
|
+
> improvement (a think-filter, `wire_messages`, a streaming printer) belongs
|
|
48
|
+
> in a planted module the harness merely references.
|
|
49
|
+
|
|
50
|
+
The harness script should asymptotically contain nothing but wiring: state
|
|
51
|
+
declarations, worker instantiation, the driving loop. If a module upgrade
|
|
52
|
+
would be blocked by a harness edit, the module was extracted at the wrong
|
|
53
|
+
boundary. Two design pressures — legibility and upgradeability — enforcing the
|
|
54
|
+
same rule is a sign the architecture is right.
|
|
55
|
+
|
|
56
|
+
The harness itself is **owned from day one**: planted once at `lyman new`,
|
|
57
|
+
marked owned in the manifest, never a target of `lyman update`. If a new
|
|
58
|
+
lyman version has a better harness shape, that ships as an advisory ("a new
|
|
59
|
+
example harness is available; see diff"), never a merge.
|
|
60
|
+
|
|
61
|
+
## The manifest
|
|
62
|
+
|
|
63
|
+
Comments in planted files ("managed by lyman — extend, don't modify") are
|
|
64
|
+
social signaling; the mechanism is a manifest written at plant time —
|
|
65
|
+
`.lyman/manifest.yml` — recording each managed artifact's source version and
|
|
66
|
+
content hash. `lyman update` then has three cheap cases per file:
|
|
67
|
+
|
|
68
|
+
- **Hash matches** — pristine; replace silently. Smooth sailing, verified
|
|
69
|
+
rather than hoped-for.
|
|
70
|
+
- **Hash differs** — modified; halt with a message. Because lyman knows
|
|
71
|
+
exactly which version it planted, it can show a real three-way diff
|
|
72
|
+
(pristine-as-planted → user's copy, pristine-as-planted → new pristine)
|
|
73
|
+
instead of "something changed."
|
|
74
|
+
- **Not in manifest** — user-owned; never touched.
|
|
75
|
+
|
|
76
|
+
Every entry also records the artifact's planted `path`. Artifact names are
|
|
77
|
+
logical identities (the changelog speaks in module names, and a name survives
|
|
78
|
+
upstream relocating a file); the path is where *this project's* copy lives.
|
|
79
|
+
Recording it keeps each entry self-describing — an entry for an artifact some
|
|
80
|
+
future lyman release renamed or dropped still names its file — and lets
|
|
81
|
+
lifecycle commands operate from the manifest alone, consulting the registry
|
|
82
|
+
only for what the current release would plant. The pristine cache mirrors the
|
|
83
|
+
planted tree (`.lyman/pristine/lib/lyman/…`), so neither store depends on
|
|
84
|
+
artifact names staying collision-free forever. When a release's destination
|
|
85
|
+
disagrees with the manifest's path, `update` halts rather than guessing which
|
|
86
|
+
references a move would break.
|
|
87
|
+
|
|
88
|
+
Per-artifact versioning falls out of the manifest for free: the changelog can
|
|
89
|
+
speak in terms of named modules ("`think_filter` 0.3 → 0.4: constructor now
|
|
90
|
+
takes…"), and `update` emits deprecation notices only for modules the project
|
|
91
|
+
actually has. Breaking changes to any planted artifact are therefore a
|
|
92
|
+
first-class release concern — a halt with a clear, specific message is the
|
|
93
|
+
contract that lets the programmer (and their coding agent) resolve breakage
|
|
94
|
+
before proceeding.
|
|
95
|
+
|
|
96
|
+
Namespacing reinforces the boundary visibly: `Lyman::` = managed, the
|
|
97
|
+
project's own namespace = owned. The line is legible in every constant
|
|
98
|
+
reference.
|
|
99
|
+
|
|
100
|
+
## Eject-to-own
|
|
101
|
+
|
|
102
|
+
"Don't modify, extend" must not read as glass over the guts. The guidance is
|
|
103
|
+
reframed from prohibition to **ownership transfer**: modifying a managed
|
|
104
|
+
module is legitimate — it means you've adopted it, and lyman stops managing
|
|
105
|
+
it. `lyman eject think_filter` makes the transfer an explicit verb rather than
|
|
106
|
+
a hash mismatch discovered later.
|
|
107
|
+
|
|
108
|
+
Ejection leaves a **tombstone record**, not a deleted entry:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
think_filter:
|
|
112
|
+
status: ejected # was: managed
|
|
113
|
+
ejected_at: 0.3.0 # lyman version when the dev took ownership
|
|
114
|
+
path: lib/lyman/think_filter.rb # where it was planted; the fork lives here
|
|
115
|
+
pristine_hash: abc123 # hash of the pristine copy at ejection time
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The tombstone buys three things a bare deletion can't:
|
|
119
|
+
|
|
120
|
+
1. **Advisories.** `lyman update` gains a third message tier: managed files
|
|
121
|
+
upgrade, unknown files are ignored, ejected files get an informational
|
|
122
|
+
notice — "`think_filter` (ejected at 0.3.0) has upstream changes in 0.5.0;
|
|
123
|
+
run `lyman diff think_filter` to compare." No action, no halt; the dev or
|
|
124
|
+
agent decides whether the upstream improvement is worth porting into their
|
|
125
|
+
fork.
|
|
126
|
+
2. **A meaningful diff.** `ejected_at` is the fork point. Without it the only
|
|
127
|
+
diff available is "upstream vs. your file," which mixes upstream's changes
|
|
128
|
+
with the dev's. With it, `lyman diff` shows *only what upstream changed
|
|
129
|
+
since the fork* — the same reason git tracks merge bases.
|
|
130
|
+
3. **Collision safety.** A later `lyman add think_filter` knows there's
|
|
131
|
+
history and asks rather than clobbers.
|
|
132
|
+
|
|
133
|
+
Heritage lives in the manifest, not in the file's identity. Renaming or
|
|
134
|
+
re-namespacing an ejected file would sever exactly the thread that makes the
|
|
135
|
+
advisory possible, and break every reference in the harness for no gain. The
|
|
136
|
+
file stays where it is, named what it is; ejection changes only who the
|
|
137
|
+
manifest says maintains it.
|
|
138
|
+
|
|
139
|
+
Open detail (decide when the command exists): advisories should probably fire
|
|
140
|
+
once per new upstream version (a `notified:` field) rather than on every
|
|
141
|
+
`update`, so a long-lived fork doesn't nag forever.
|
|
142
|
+
|
|
143
|
+
## Guidance for coding agents
|
|
144
|
+
|
|
145
|
+
Deployment includes deploying *understanding* — shifty and lyman are not
|
|
146
|
+
complicated once you know how to use them, but how to use them is not obvious.
|
|
147
|
+
Three channels, ordered by reach:
|
|
148
|
+
|
|
149
|
+
1. **Scaffolded into the project.** `lyman new` emits a `CLAUDE.md` (or
|
|
150
|
+
`AGENTS.md`) as a first-class artifact carrying the load-bearing facts: the
|
|
151
|
+
nil-source footgun, the runaway-turn guard, item-as-control discipline,
|
|
152
|
+
wire-vs-conversation separation. Highest-value channel: works with any
|
|
153
|
+
coding agent, zero install steps, travels with the code.
|
|
154
|
+
2. **A Claude plugin marketplace** in the `joelhelbling/lyman` repo, for
|
|
155
|
+
richer *procedures* — e.g. a skill that knows how to wire a new tool worker
|
|
156
|
+
correctly, or how to read a stalled pipeline. Skills earn their keep when
|
|
157
|
+
there's a procedure; facts belong in the scaffolded doc.
|
|
158
|
+
3. **Published docs / `llms.txt`** for indexing, so agents that installed
|
|
159
|
+
nothing still find accurate usage docs rather than hallucinating a shifty
|
|
160
|
+
API.
|
|
161
|
+
|
|
162
|
+
## Ruby without a type checker
|
|
163
|
+
|
|
164
|
+
Copy-in plus extension-over-modification is where statically typed languages
|
|
165
|
+
would catch contract drift at compile time. What replaces the compiler here:
|
|
166
|
+
|
|
167
|
+
1. **A tiny contract surface.** A shifty worker's interface is "take an item,
|
|
168
|
+
yield an item." The real contract is the item shape — `Conversation` and
|
|
169
|
+
its string-keyed messages. One class; most breaking changes will be changes
|
|
170
|
+
to it, so compatibility care concentrates there.
|
|
171
|
+
2. **Errors designed for agents.** `lyman update` failures must be structured
|
|
172
|
+
and specific ("`think_filter` modified since 0.3.0; pristine copy at …;
|
|
173
|
+
diff: …") the way a compiler error names file, line, and expectation. A
|
|
174
|
+
good error message is a type checker with one-round-trip latency.
|
|
175
|
+
3. **A runnable check.** `lyman doctor`: run the project's pipeline against a
|
|
176
|
+
stub model transport and assert a well-formed `Conversation` comes out the
|
|
177
|
+
other end. A smoke-level contract test `update` can run automatically
|
|
178
|
+
post-upgrade — the closest thing to "it compiles" a duck-typed pipeline can
|
|
179
|
+
offer.
|
|
180
|
+
|
|
181
|
+
Honest caveat: the manifest catches modified *files*, not broken *extensions*.
|
|
182
|
+
A subclass of a managed module can break when the parent changes even though
|
|
183
|
+
every hash matches. `doctor` is the backstop, and it's another reason managed
|
|
184
|
+
modules keep their public surfaces deliberately small and dull.
|
|
185
|
+
|
|
186
|
+
## Practical notes
|
|
187
|
+
|
|
188
|
+
- The `lyman` gem name is unclaimed on rubygems.org as of 2026-07-04; register
|
|
189
|
+
early, even as a placeholder.
|
|
190
|
+
- No runtime `lyman chat` command. The moment the CLI *runs* harnesses rather
|
|
191
|
+
than *generating* them, the shell stops being the user's visible loop.
|
|
192
|
+
- This repo pins Ruby via `mise.toml`; scaffolded projects should declare a
|
|
193
|
+
looser constraint, and install docs should assume a user starting from zero
|
|
194
|
+
Ruby tooling (the target user is "person with Ollama running," not
|
|
195
|
+
necessarily a Rubyist).
|