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 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).