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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Lyman
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `lyman doctor` — smoke-tests a scaffolded project: manifest parses,
|
|
7
|
+
# every listed file exists (managed files note pristine/modified), and
|
|
8
|
+
# a subprocess runs the planted pipeline end to end against a stub
|
|
9
|
+
# transport (lib/lyman/cli/doctor_check.rb). See docs/design/
|
|
10
|
+
# deployment.md ("Ruby without a type checker").
|
|
11
|
+
class Doctor
|
|
12
|
+
# doctor_check.rb is CLI machinery, not a planted artifact — it always
|
|
13
|
+
# ships from this gem's own tree, never from a fixture source_root
|
|
14
|
+
# (source_root exists so other commands can point at "a newer lyman
|
|
15
|
+
# release"; doctor has no such concept, it only checks what's here).
|
|
16
|
+
CHECK_SCRIPT = File.join(Registry::GEM_ROOT, "lib", "lyman", "cli", "doctor_check.rb")
|
|
17
|
+
|
|
18
|
+
def initialize(thor, source_root:)
|
|
19
|
+
@thor = thor
|
|
20
|
+
@source_root = source_root
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
project_root = Manifest.find!
|
|
25
|
+
manifest = load_manifest(project_root)
|
|
26
|
+
|
|
27
|
+
results = [!manifest.nil?]
|
|
28
|
+
if manifest
|
|
29
|
+
results << check_files(manifest, project_root)
|
|
30
|
+
results << check_pipeline(project_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
exit 1 unless results.all?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check 1: the manifest itself parses. `Manifest.find!` already
|
|
39
|
+
# confirmed the file exists; the read can still blow up on malformed
|
|
40
|
+
# YAML, and that's worth its own ✓/✗ line rather than a raw backtrace.
|
|
41
|
+
def load_manifest(project_root)
|
|
42
|
+
manifest = Manifest.load(project_root)
|
|
43
|
+
report(true, "manifest parses (#{manifest.path})")
|
|
44
|
+
manifest
|
|
45
|
+
rescue => e
|
|
46
|
+
report(false, "manifest failed to parse: #{e.message}")
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check 2: every manifest-listed file exists. Managed files also get
|
|
51
|
+
# a pristine/modified note — modified is informational (that's
|
|
52
|
+
# `update`'s job to flag as a halt), not a doctor failure. Each entry
|
|
53
|
+
# records its own path, so this check needs no registry: even
|
|
54
|
+
# artifacts a future lyman release no longer knows about get checked.
|
|
55
|
+
def check_files(manifest, project_root)
|
|
56
|
+
ok = true
|
|
57
|
+
|
|
58
|
+
manifest.artifacts.each do |name, entry|
|
|
59
|
+
next unless entry["path"] # nothing to check without a location
|
|
60
|
+
|
|
61
|
+
dest = File.join(project_root, entry["path"])
|
|
62
|
+
unless File.exist?(dest)
|
|
63
|
+
report(false, "#{name}: planted file missing (#{dest})")
|
|
64
|
+
ok = false
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if entry["status"] == "managed"
|
|
69
|
+
current_hash = Planter.hash(File.read(dest))
|
|
70
|
+
note = (current_hash == entry["hash"]) ? "pristine" : "modified"
|
|
71
|
+
report(true, "#{name}: file present (#{note})")
|
|
72
|
+
else
|
|
73
|
+
report(true, "#{name}: file present")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
ok
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check 3: run the planted pipeline against a stub transport, in a
|
|
81
|
+
# subprocess. It must be a subprocess — the client project has its
|
|
82
|
+
# own `Lyman::` constants (Conversation, Workers) that would collide
|
|
83
|
+
# with this CLI process if we required them in-process, and the
|
|
84
|
+
# client's bundle (not the CLI's) is what needs to resolve shifty.
|
|
85
|
+
def check_pipeline(project_root)
|
|
86
|
+
cmd = subprocess_command(project_root)
|
|
87
|
+
out, status = Open3.capture2e(*cmd, chdir: project_root)
|
|
88
|
+
|
|
89
|
+
# doctor_check.rb prints its own ✓/✗ verdict line; relay it
|
|
90
|
+
# verbatim rather than re-deriving one, so the subprocess's
|
|
91
|
+
# explanation of *why* it failed (if it did) reaches the user.
|
|
92
|
+
out.each_line { |line| @thor.say line.chomp }
|
|
93
|
+
status.success?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A freshly scaffolded project has no Gemfile.lock until the user
|
|
97
|
+
# runs `bundle install` — `lyman doctor` should still work at that
|
|
98
|
+
# point, so it falls back to plain `ruby` with explicit `-I` load
|
|
99
|
+
# paths for shifty/ostruct, borrowed from the CLI process's own
|
|
100
|
+
# activated gems (Gem.loaded_specs — populated by Bundler when this
|
|
101
|
+
# CLI itself was launched via `bundle exec`/the installed gem).
|
|
102
|
+
# Once the client has a lock, `bundle exec` lets their own bundle
|
|
103
|
+
# (which might pin a different shifty version) resolve instead.
|
|
104
|
+
def subprocess_command(project_root)
|
|
105
|
+
if File.exist?(File.join(project_root, "Gemfile.lock"))
|
|
106
|
+
["bundle", "exec", "ruby", CHECK_SCRIPT]
|
|
107
|
+
else
|
|
108
|
+
load_paths = %w[shifty ostruct].filter_map do |gem_name|
|
|
109
|
+
spec = Gem.loaded_specs[gem_name]
|
|
110
|
+
"#{spec.gem_dir}/lib" if spec
|
|
111
|
+
end
|
|
112
|
+
["ruby", *load_paths.flat_map { |p| ["-I", p] }, CHECK_SCRIPT]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def report(ok, message)
|
|
117
|
+
@thor.say "#{ok ? "✓" : "✗"} #{message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Lyman
|
|
2
|
+
module CLI
|
|
3
|
+
module Commands
|
|
4
|
+
# `lyman eject ARTIFACT` — rewrites a managed artifact's manifest
|
|
5
|
+
# entry into a tombstone (status: ejected, ejected_at, pristine_hash),
|
|
6
|
+
# transferring ownership without deleting history. See docs/design/
|
|
7
|
+
# deployment.md ("Eject-to-own"). The pristine copy is left in place —
|
|
8
|
+
# it becomes the fork point that makes a later `lyman diff` meaningful.
|
|
9
|
+
class Eject
|
|
10
|
+
def initialize(thor, source_root:)
|
|
11
|
+
@thor = thor
|
|
12
|
+
@source_root = source_root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(artifact)
|
|
16
|
+
project_root = Manifest.find!
|
|
17
|
+
manifest = Manifest.load(project_root)
|
|
18
|
+
name = Registry.resolve(artifact, manifest: manifest)
|
|
19
|
+
entry = manifest.artifact(name)
|
|
20
|
+
|
|
21
|
+
case entry&.fetch("status", nil)
|
|
22
|
+
when "managed"
|
|
23
|
+
eject(manifest, name, entry)
|
|
24
|
+
when "owned"
|
|
25
|
+
@thor.say "#{name} is already yours; lyman never manages #{entry["path"]}."
|
|
26
|
+
when "ejected"
|
|
27
|
+
@thor.say "#{name} was already ejected at #{entry["ejected_at"]}; nothing to do."
|
|
28
|
+
else
|
|
29
|
+
raise Thor::Error, "#{name} isn't planted in this project; nothing to eject."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The tombstone keeps the path: it's what lets `diff` and `doctor`
|
|
36
|
+
# find the fork without consulting a registry that may no longer
|
|
37
|
+
# know this artifact.
|
|
38
|
+
def eject(manifest, name, entry)
|
|
39
|
+
manifest.set_artifact(name, {
|
|
40
|
+
"status" => "ejected",
|
|
41
|
+
"ejected_at" => Lyman::CLI::VERSION,
|
|
42
|
+
"path" => entry["path"],
|
|
43
|
+
"pristine_hash" => entry["hash"]
|
|
44
|
+
})
|
|
45
|
+
manifest.save
|
|
46
|
+
|
|
47
|
+
@thor.say "#{name} is yours now. lyman will note upstream changes to it " \
|
|
48
|
+
"(run `lyman diff #{name}` to compare) but will never touch the file again."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Lyman
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `lyman new NAME` — the one command that doesn't need an existing
|
|
7
|
+
# project: it creates one, planting every registry artifact and
|
|
8
|
+
# writing the manifest that makes `update`/`eject`/`diff` possible
|
|
9
|
+
# afterward.
|
|
10
|
+
class New
|
|
11
|
+
def initialize(thor, source_root:)
|
|
12
|
+
@thor = thor
|
|
13
|
+
@source_root = source_root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(name)
|
|
17
|
+
project_root = File.expand_path(name)
|
|
18
|
+
refuse_nonempty!(project_root, name)
|
|
19
|
+
FileUtils.mkdir_p(project_root)
|
|
20
|
+
|
|
21
|
+
manifest = Manifest.load(project_root)
|
|
22
|
+
Registry::ARTIFACTS.each do |artifact_name, spec|
|
|
23
|
+
plant(manifest, artifact_name, spec, project_root)
|
|
24
|
+
end
|
|
25
|
+
manifest.save
|
|
26
|
+
|
|
27
|
+
epilogue(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def refuse_nonempty!(project_root, name)
|
|
33
|
+
return unless Dir.exist?(project_root)
|
|
34
|
+
return if Dir.empty?(project_root)
|
|
35
|
+
|
|
36
|
+
raise Thor::Error, "#{name} already exists and is not empty; " \
|
|
37
|
+
"choose a different name or clear the directory first."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The manifest records where each artifact was planted, so every
|
|
41
|
+
# entry stays self-describing even if a later lyman release renames
|
|
42
|
+
# or drops the artifact — lifecycle commands read the path from
|
|
43
|
+
# here, not from the registry.
|
|
44
|
+
def plant(manifest, artifact_name, spec, project_root)
|
|
45
|
+
bytes = Planter.plant(artifact_name, spec, project_root: project_root, source_root: @source_root)
|
|
46
|
+
manifest.write_pristine(spec[:dest], bytes)
|
|
47
|
+
|
|
48
|
+
attrs = {
|
|
49
|
+
"status" => spec[:role].to_s,
|
|
50
|
+
"planted_at" => Lyman::CLI::VERSION,
|
|
51
|
+
"path" => spec[:dest]
|
|
52
|
+
}
|
|
53
|
+
attrs["hash"] = Planter.hash(bytes) if spec[:role] == :managed
|
|
54
|
+
manifest.set_artifact(artifact_name, attrs)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def epilogue(name)
|
|
58
|
+
@thor.say <<~EPILOGUE
|
|
59
|
+
|
|
60
|
+
Planted a new lyman project in #{name}.
|
|
61
|
+
|
|
62
|
+
Next steps:
|
|
63
|
+
cd #{name}
|
|
64
|
+
bundle install
|
|
65
|
+
ruby harness/chat.rb # defaults to Ollama at http://localhost:11434/v1
|
|
66
|
+
lyman doctor # smoke-test the pipeline
|
|
67
|
+
EPILOGUE
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module Lyman
|
|
2
|
+
module CLI
|
|
3
|
+
module Commands
|
|
4
|
+
# `lyman update` — walks the manifest (never the filesystem) and
|
|
5
|
+
# reconciles every managed artifact against the current lyman release.
|
|
6
|
+
# Three tiers per docs/design/deployment.md: managed artifacts either
|
|
7
|
+
# replace silently (pristine) or halt with a structured message
|
|
8
|
+
# (modified/missing); owned artifacts are the user's and are skipped;
|
|
9
|
+
# ejected artifacts get a one-time advisory. Artifacts not in the
|
|
10
|
+
# manifest are never even looked at.
|
|
11
|
+
class Update
|
|
12
|
+
def initialize(thor, source_root:)
|
|
13
|
+
@thor = thor
|
|
14
|
+
@source_root = source_root
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
project_root = Manifest.find!
|
|
19
|
+
manifest = Manifest.load(project_root)
|
|
20
|
+
|
|
21
|
+
updated = []
|
|
22
|
+
up_to_date = []
|
|
23
|
+
halted = []
|
|
24
|
+
advisories = []
|
|
25
|
+
|
|
26
|
+
manifest.artifacts.each_key.to_a.each do |name|
|
|
27
|
+
spec = Registry::ARTIFACTS[name]
|
|
28
|
+
next unless spec # unknown-to-this-release entries are left alone
|
|
29
|
+
|
|
30
|
+
entry = manifest.artifact(name)
|
|
31
|
+
case entry["status"]
|
|
32
|
+
when "managed"
|
|
33
|
+
reconcile_managed(manifest, project_root, name, spec, entry, updated, up_to_date, halted)
|
|
34
|
+
when "owned"
|
|
35
|
+
next # the user's file; update never touches it
|
|
36
|
+
when "ejected"
|
|
37
|
+
reconcile_ejected(manifest, name, spec, entry, advisories)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
manifest.save
|
|
42
|
+
summarize(updated, up_to_date, halted, advisories)
|
|
43
|
+
exit 1 unless halted.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The manifest's recorded path — not the registry's dest — says where
|
|
49
|
+
# this project's copy lives; the registry only says where the current
|
|
50
|
+
# release would plant a fresh one. When the two disagree (someone
|
|
51
|
+
# moved the file, or upstream relocated the artifact), that's a halt:
|
|
52
|
+
# update can't know which references would break by moving it.
|
|
53
|
+
def reconcile_managed(manifest, project_root, name, spec, entry, updated, up_to_date, halted)
|
|
54
|
+
path = entry["path"]
|
|
55
|
+
dest = File.join(project_root, path)
|
|
56
|
+
|
|
57
|
+
if spec[:dest] != path
|
|
58
|
+
halted << {name: name, reason: :relocated, path: path, upstream_dest: spec[:dest]}
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
unless File.exist?(dest)
|
|
63
|
+
halted << {name: name, reason: :missing, planted_at: entry["planted_at"], path: path}
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
current_bytes = File.read(dest)
|
|
68
|
+
current_hash = Planter.hash(current_bytes)
|
|
69
|
+
|
|
70
|
+
if current_hash != entry["hash"]
|
|
71
|
+
halted << {name: name, reason: :modified, planted_at: entry["planted_at"], path: path}
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
rendered = Planter.render(name, spec, source_root: @source_root)
|
|
76
|
+
new_hash = Planter.hash(rendered)
|
|
77
|
+
|
|
78
|
+
if new_hash == current_hash
|
|
79
|
+
up_to_date << name
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
from_version = entry["planted_at"]
|
|
84
|
+
Planter.plant(name, spec, project_root: project_root, source_root: @source_root)
|
|
85
|
+
manifest.write_pristine(path, rendered)
|
|
86
|
+
manifest.set_artifact(name, entry.merge(
|
|
87
|
+
"hash" => new_hash,
|
|
88
|
+
"planted_at" => Lyman::CLI::VERSION
|
|
89
|
+
))
|
|
90
|
+
updated << {name: name, from: from_version, to: Lyman::CLI::VERSION}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Ejected artifacts are never touched — the manifest just tracks
|
|
94
|
+
# whether upstream has moved since the fork point, and fires the
|
|
95
|
+
# advisory at most once per upstream version so a long-lived fork
|
|
96
|
+
# doesn't nag forever.
|
|
97
|
+
def reconcile_ejected(manifest, name, spec, entry, advisories)
|
|
98
|
+
rendered = Planter.render(name, spec, source_root: @source_root)
|
|
99
|
+
current_hash = Planter.hash(rendered)
|
|
100
|
+
|
|
101
|
+
return if current_hash == entry["pristine_hash"]
|
|
102
|
+
return if entry["notified"] == Lyman::CLI::VERSION
|
|
103
|
+
|
|
104
|
+
advisories << {name: name, ejected_at: entry["ejected_at"], upstream: Lyman::CLI::VERSION}
|
|
105
|
+
manifest.set_artifact(name, entry.merge("notified" => Lyman::CLI::VERSION))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def summarize(updated, up_to_date, halted, advisories)
|
|
109
|
+
updated.each do |u|
|
|
110
|
+
@thor.say "updated #{u[:name]} (#{u[:from]} → #{u[:to]})"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
up_to_date.each do |name|
|
|
114
|
+
@thor.say "#{name} up to date"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
halted.each do |h|
|
|
118
|
+
@thor.say(halt_message(h), :red)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
advisories.each do |a|
|
|
122
|
+
@thor.say "#{a[:name]} (ejected at #{a[:ejected_at]}) has upstream changes in " \
|
|
123
|
+
"#{a[:upstream]}; run `lyman diff #{a[:name]}` to compare.", :yellow
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@thor.say [
|
|
127
|
+
"#{updated.size} updated",
|
|
128
|
+
"#{up_to_date.size} up to date",
|
|
129
|
+
"#{halted.size} halted",
|
|
130
|
+
"#{advisories.size} advisories"
|
|
131
|
+
].join(", ")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def halt_message(halted)
|
|
135
|
+
case halted[:reason]
|
|
136
|
+
when :missing
|
|
137
|
+
"#{halted[:name]} (planted at #{halted[:planted_at]}) is missing its planted file " \
|
|
138
|
+
"(#{halted[:path]}); run `lyman add #{halted[:name]} --force` to replant it."
|
|
139
|
+
when :modified
|
|
140
|
+
"#{halted[:name]} (planted at #{halted[:planted_at]}) has been modified since planting; " \
|
|
141
|
+
"pristine copy at .lyman/pristine/#{halted[:path]}. " \
|
|
142
|
+
"Run `lyman diff #{halted[:name]}` to see changes, or " \
|
|
143
|
+
"`lyman eject #{halted[:name]}` to take ownership."
|
|
144
|
+
when :relocated
|
|
145
|
+
"#{halted[:name]} lives at #{halted[:path]} in this project, but this lyman release " \
|
|
146
|
+
"plants it at #{halted[:upstream_dest]}. Move the file (and any references to it), " \
|
|
147
|
+
"set its `path:` in .lyman/manifest.yml to match, and rerun `lyman update` — or " \
|
|
148
|
+
"run `lyman eject #{halted[:name]}` to keep it where it is, as yours."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
#
|
|
3
|
+
# lyman doctor's pipeline smoke test, run as a subprocess inside the client
|
|
4
|
+
# project (see lib/lyman/cli/commands/doctor.rb for why it must be a
|
|
5
|
+
# subprocess: the client's Lyman:: constants must not collide with this CLI
|
|
6
|
+
# process's own, and it's the client's bundle — not the CLI's — that needs
|
|
7
|
+
# to resolve shifty).
|
|
8
|
+
#
|
|
9
|
+
# Wires the same circuit shape as harness/chat.rb (docs/design/
|
|
10
|
+
# circuit-pattern.md): a queue-backed source, the model⇄tool round, the
|
|
11
|
+
# finish/requeue plumbing. The only substitution is the transport: a stub
|
|
12
|
+
# stands in at the chat_completion seam. That's legitimate, not a mock
|
|
13
|
+
# reaching inside — the model transport is itself a swappable worker (see
|
|
14
|
+
# docs/design/deployment.md, "Ruby without a type checker"), so a stub here
|
|
15
|
+
# is an alternate implementation of the seam, and every other stage (tool
|
|
16
|
+
# execution, requeue, the finished-turn filter) still runs for real.
|
|
17
|
+
|
|
18
|
+
require "./lib/lyman"
|
|
19
|
+
|
|
20
|
+
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
21
|
+
|
|
22
|
+
conversation = Lyman::Conversation.new(system_prompt: "doctor")
|
|
23
|
+
conversation.add_user_message("ping the tool once, then answer")
|
|
24
|
+
rounds = [] # the circuit's queue, shell scope — mirrors harness/chat.rb
|
|
25
|
+
|
|
26
|
+
# Stands in for Lyman::Workers.chat_completion: round 1 calls the "ping"
|
|
27
|
+
# tool, round 2 answers plainly. A real transport wouldn't know the round
|
|
28
|
+
# count in advance, but the seam only cares that something plays by the
|
|
29
|
+
# wire's rules — append an assistant message, bump the round counter.
|
|
30
|
+
call_count = 0
|
|
31
|
+
stub_transport = relay_worker do |c|
|
|
32
|
+
call_count += 1
|
|
33
|
+
message =
|
|
34
|
+
if call_count == 1
|
|
35
|
+
{
|
|
36
|
+
"role" => "assistant",
|
|
37
|
+
"content" => nil,
|
|
38
|
+
"tool_calls" => [
|
|
39
|
+
{
|
|
40
|
+
"id" => "call_1",
|
|
41
|
+
"type" => "function",
|
|
42
|
+
"function" => {"name" => "ping", "arguments" => "{}"}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
{"role" => "assistant", "content" => "done"}
|
|
48
|
+
end
|
|
49
|
+
c.add_assistant_message(message)
|
|
50
|
+
c.rounds += 1
|
|
51
|
+
c
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
pipeline =
|
|
55
|
+
source_worker { rounds.shift } |
|
|
56
|
+
stub_transport |
|
|
57
|
+
relay_worker { |c|
|
|
58
|
+
c.finish! if c.pending_tool_calls.empty? || c.runaway?
|
|
59
|
+
c
|
|
60
|
+
} |
|
|
61
|
+
Lyman::Workers.tool_execution({"ping" => ->(_) { "pong" }}) |
|
|
62
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
63
|
+
filter_worker { |c| c.finished? }
|
|
64
|
+
|
|
65
|
+
# Never pull while the queue is empty (the nil footgun, CLAUDE.md) — enqueue
|
|
66
|
+
# the seed conversation before the first shift.
|
|
67
|
+
rounds << conversation
|
|
68
|
+
result = pipeline.shift
|
|
69
|
+
|
|
70
|
+
failures = []
|
|
71
|
+
failures << "conversation never finished" unless result.finished?
|
|
72
|
+
|
|
73
|
+
unless result.messages.all? { |m| m.keys.all? { |k| k.is_a?(String) } }
|
|
74
|
+
failures << "a message used non-string keys"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
valid_roles = %w[system user assistant tool]
|
|
78
|
+
unless result.messages.all? { |m| valid_roles.include?(m["role"]) }
|
|
79
|
+
failures << "a message had a role outside system|user|assistant|tool"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
tool_message = result.messages.find { |m| m["role"] == "tool" }
|
|
83
|
+
if tool_message.nil?
|
|
84
|
+
failures << "no tool result message found"
|
|
85
|
+
elsif tool_message["tool_call_id"] != "call_1"
|
|
86
|
+
failures << "tool result did not answer the expected tool call id"
|
|
87
|
+
elsif tool_message["content"] != "pong"
|
|
88
|
+
failures << "tool result content was #{tool_message["content"].inspect}, expected \"pong\""
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if failures.empty?
|
|
92
|
+
puts "✓ pipeline smoke test passed (#{result.messages.size} messages, tool round-trip ok)"
|
|
93
|
+
exit 0
|
|
94
|
+
else
|
|
95
|
+
puts "✗ pipeline smoke test failed: #{failures.join("; ")}"
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Lyman
|
|
5
|
+
module CLI
|
|
6
|
+
# Reads and writes <project_root>/.lyman/manifest.yml — the record that
|
|
7
|
+
# makes `update` cheap (hash comparison instead of guessing) and `eject`
|
|
8
|
+
# meaningful (a tombstone, not a deletion). See docs/design/deployment.md.
|
|
9
|
+
class Manifest
|
|
10
|
+
RELATIVE_PATH = File.join(".lyman", "manifest.yml")
|
|
11
|
+
PRISTINE_DIR = File.join(".lyman", "pristine")
|
|
12
|
+
|
|
13
|
+
attr_reader :project_root
|
|
14
|
+
|
|
15
|
+
# Walks up from +start_dir+ looking for .lyman/manifest.yml, the same
|
|
16
|
+
# way git walks up looking for .git. Returns the project root or nil.
|
|
17
|
+
def self.find(start_dir = Dir.pwd)
|
|
18
|
+
dir = File.expand_path(start_dir)
|
|
19
|
+
loop do
|
|
20
|
+
return dir if File.exist?(File.join(dir, RELATIVE_PATH))
|
|
21
|
+
parent = File.dirname(dir)
|
|
22
|
+
break if parent == dir
|
|
23
|
+
dir = parent
|
|
24
|
+
end
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Every command except `new` needs a project to act on; this is the one
|
|
29
|
+
# place that error is raised, worded for an agent to act on directly.
|
|
30
|
+
def self.find!(start_dir = Dir.pwd)
|
|
31
|
+
find(start_dir) || raise(Thor::Error, <<~MSG.strip)
|
|
32
|
+
Not inside a lyman project (no .lyman/manifest.yml found walking up
|
|
33
|
+
from #{start_dir}). Run `lyman new NAME` to create one.
|
|
34
|
+
MSG
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.load(project_root)
|
|
38
|
+
new(project_root)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(project_root)
|
|
42
|
+
@project_root = project_root
|
|
43
|
+
@data = read
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def path
|
|
47
|
+
File.join(project_root, RELATIVE_PATH)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lyman_version
|
|
51
|
+
@data["lyman"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def artifacts
|
|
55
|
+
@data["artifacts"] ||= {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def artifact(name)
|
|
59
|
+
artifacts[name]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_artifact(name, attrs)
|
|
63
|
+
artifacts[name] = attrs
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete_artifact(name)
|
|
67
|
+
artifacts.delete(name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def save
|
|
71
|
+
@data["lyman"] = Lyman::CLI::VERSION
|
|
72
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
73
|
+
File.write(path, dump)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The pristine cache mirrors the planted tree (.lyman/pristine/lib/
|
|
78
|
+
# lyman/conversation.rb, not a flat name) — collision-proof, and
|
|
79
|
+
# browsable as a shadow of exactly what was planted where.
|
|
80
|
+
def pristine_path(rel_path)
|
|
81
|
+
File.join(project_root, PRISTINE_DIR, rel_path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write_pristine(rel_path, bytes)
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(pristine_path(rel_path)))
|
|
86
|
+
File.write(pristine_path(rel_path), bytes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_pristine(rel_path)
|
|
90
|
+
File.read(pristine_path(rel_path))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def pristine?(rel_path)
|
|
94
|
+
File.exist?(pristine_path(rel_path))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Stable key order (lyman, then artifacts sorted by name) so the
|
|
100
|
+
# manifest's own diffs stay calm across runs.
|
|
101
|
+
def dump
|
|
102
|
+
ordered = {"lyman" => @data["lyman"] || Lyman::CLI::VERSION}
|
|
103
|
+
ordered["artifacts"] = artifacts.sort.to_h
|
|
104
|
+
YAML.dump(ordered)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def read
|
|
108
|
+
return {"lyman" => Lyman::CLI::VERSION, "artifacts" => {}} unless File.exist?(path)
|
|
109
|
+
YAML.safe_load_file(path) || {}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Lyman
|
|
5
|
+
module CLI
|
|
6
|
+
# Turns a registry entry into planted bytes and writes them. Rendering
|
|
7
|
+
# and hashing are kept together deliberately: the hash must always be a
|
|
8
|
+
# hash of what actually landed on disk (banner included), never of the
|
|
9
|
+
# bare source file, or `update` would flag every managed artifact as
|
|
10
|
+
# locally modified on first run.
|
|
11
|
+
module Planter
|
|
12
|
+
BANNER = <<~RUBY
|
|
13
|
+
# Managed by lyman — extend, don't modify. `lyman eject %<name>s` takes
|
|
14
|
+
# ownership; see .lyman/manifest.yml.
|
|
15
|
+
|
|
16
|
+
RUBY
|
|
17
|
+
|
|
18
|
+
def self.render(name, spec, source_root: Registry::GEM_ROOT)
|
|
19
|
+
source = File.read(Registry.source_path(spec, source_root: source_root))
|
|
20
|
+
if spec[:role] == :managed && spec[:dest].end_with?(".rb")
|
|
21
|
+
format(BANNER, name: name) + source
|
|
22
|
+
else
|
|
23
|
+
source
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.plant(name, spec, project_root:, source_root: Registry::GEM_ROOT)
|
|
28
|
+
bytes = render(name, spec, source_root: source_root)
|
|
29
|
+
dest = File.join(project_root, spec[:dest])
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
31
|
+
File.write(dest, bytes)
|
|
32
|
+
bytes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.hash(bytes)
|
|
36
|
+
Digest::SHA256.hexdigest(bytes)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|