terret 0.0.2 → 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.
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "composition"
4
+
5
+ module Terret
6
+ # `trt doctor` resolves a profile and validates every row's config against its
7
+ # plugin's Hames::Schema, without booting anything (docs/composition.md §9).
8
+ #
9
+ # It requires the composition's code and materializes each row (so !env,
10
+ # !setting and !ruby resolve to concrete values), then maps each row's plugin
11
+ # to its class and checks the materialized config against the class's schema.
12
+ # Nothing is mounted: doctor reports on a composition it never boots.
13
+ #
14
+ # Two semantics keep the exit status trustworthy, and both err the same way:
15
+ # a service with no schema is reported `unschema'd`, not failed, and an extra
16
+ # key WARNS rather than fails. Environment probes — does OPENROUTER_API_KEY
17
+ # resolve — print as informational lines and never as failures: doctor
18
+ # validates config, not the world. Exit status is 1 only when an enabled row's
19
+ # config is actually wrong.
20
+ module Doctor
21
+ # Returns a process exit status: 1 when an enabled row's config is wrong,
22
+ # 0 otherwise.
23
+ def self.run(resolved, allow_config_ruby: false, out:)
24
+ load_failures = require_code(resolved, allow_config_ruby)
25
+ # Settings resolve once, but a bad !setting/!ruby in settings: must not
26
+ # abort the whole run and hide the row table. It becomes its own error
27
+ # line; rows then materialize against empty settings, so any !setting in a
28
+ # row surfaces as that row's own error rather than a swallowed one.
29
+ settings, settings_error = begin
30
+ [Composition.materialize_settings(resolved.settings, allow_config_ruby: allow_config_ruby), nil]
31
+ rescue Composition::Error => e
32
+ [{}, e.message]
33
+ end
34
+ results = resolved.rows.map { |row| check(row, settings, allow_config_ruby) }
35
+
36
+ render(resolved, results, load_failures, settings_error, out)
37
+ bad = settings_error || results.any? { |r| !r[:disabled] && r[:status] == :error }
38
+ bad ? 1 : 0
39
+ end
40
+
41
+ # Requiring the composition's code, best-effort: a require that fails does
42
+ # not abort doctor. It surfaces as an info line for the file, and as the
43
+ # per-row "does not resolve" error for every class that file would define —
44
+ # which is more useful than a single aborted run.
45
+ #
46
+ # doctor is the SAFE preview — "validates config, not the world" — so a
47
+ # path-shaped require in a profile's plugins: (portable config from anywhere)
48
+ # is NOT executed to do that job: it surfaces as a load failure the same way
49
+ # a missing feature does, keeping `trt doctor <untrusted profile>` from being
50
+ # arbitrary code execution. A bundle's requires: ship inside an installed gem
51
+ # and are trusted (they may name a path to their own lib);
52
+ # --allow-config-ruby is the operator's consent to load a profile path too,
53
+ # exactly as at boot.
54
+ def self.require_code(resolved, allow_config_ruby)
55
+ refused, permitted = resolved.plugins.partition do |file|
56
+ !allow_config_ruby && !Composition.load_path_feature?(file)
57
+ end
58
+ failures = (resolved.requires + permitted).filter_map do |file|
59
+ require file
60
+ nil
61
+ rescue LoadError => e
62
+ [file, e.message]
63
+ end
64
+ failures + refused.map do |file|
65
+ [file, "refused: a filesystem path, not a load-path feature name; doctor does " \
66
+ "not execute untrusted requires — pass --allow-config-ruby to load it"]
67
+ end
68
+ end
69
+
70
+ # One row's verdict. The row id names the row in the table's first column,
71
+ # so validate is called WITHOUT a subject — the schema can name the row (its
72
+ # unit tests prove it), but here the column already does, and the doc's
73
+ # `error: sink must be a String` reads better without the id repeated.
74
+ def self.check(row, settings, allow_config_ruby)
75
+ base = { id: row.id, plugin: row.plugin, disabled: row.disabled }
76
+ config = Composition.materialize(row.config, settings: settings, allow_config_ruby: allow_config_ruby,
77
+ where: "row #{row.id.inspect}")
78
+ klass = constantize(row.plugin)
79
+ return base.merge(status: :error, detail: "#{row.plugin} does not resolve to a plugin class") unless klass
80
+ unless plugin_class?(klass)
81
+ # A name that resolves to a live constant that is not a plugin (String, a
82
+ # module, a typo hitting something real) is not "a plugin with no schema"
83
+ # — it is a wrong plugin:, and reporting it unschema'd would hide that.
84
+ return base.merge(status: :error, detail: "#{row.plugin} is not a plugin " \
85
+ "(its instances do not respond to #apply)")
86
+ end
87
+
88
+ schema = klass.respond_to?(:config_schema) ? klass.config_schema : nil
89
+ return base.merge(status: :unschema, detail: nil) unless schema
90
+
91
+ # redact: a value here is a materialized !env/!setting/!ruby result and
92
+ # may be a secret; the detail names its type only, never its content.
93
+ result = schema.validate(config, redact: true)
94
+ status = if result.errors.any? then :error
95
+ elsif result.warnings.any? then :warn
96
+ else :ok
97
+ end
98
+ base.merge(status: status, detail: (result.errors + result.warnings).join("; "))
99
+ rescue Composition::Error => e
100
+ # A row whose !setting or !ruby cannot resolve is a config fault doctor
101
+ # owns, reported against the row rather than crashing every other row.
102
+ base.merge(status: :error, detail: e.message)
103
+ end
104
+
105
+ def self.constantize(name)
106
+ Object.const_get(name)
107
+ rescue NameError
108
+ nil
109
+ end
110
+
111
+ # The plugin contract boot itself enforces (boot.rb): a class whose instances
112
+ # respond to #apply. Using the same test keeps doctor from red-flagging a
113
+ # composition boot would accept — including a third-party functional plugin
114
+ # that is not a Hames::Service.
115
+ def self.plugin_class?(klass)
116
+ klass.is_a?(Class) && klass.method_defined?(:apply)
117
+ end
118
+
119
+ # -- output ----------------------------------------------------------------
120
+
121
+ def self.render(resolved, results, load_failures, settings_error, out)
122
+ out.puts "# doctor: profile #{resolved.profile.inspect}"
123
+ out.puts
124
+ if settings_error
125
+ out.puts "error #{Composition.one_line(settings_error)}"
126
+ out.puts
127
+ end
128
+
129
+ # Row ids are validated at resolution, but plugin names, error details,
130
+ # env markers and file paths are not — a newline in any of them could
131
+ # forge a table row, so every printed value goes through one_line. Widths
132
+ # are computed on the neutralized plugin so the table stays aligned.
133
+ plugins = results.to_h { |r| [r[:id], Composition.one_line(r[:plugin])] }
134
+ row_w = [results.map { |r| r[:id].length }.max || 3, 3].max
135
+ plugin_w = [plugins.values.map(&:length).max || 6, 6].max
136
+ out.puts "#{'row'.ljust(row_w)} #{'plugin'.ljust(plugin_w)} status"
137
+ results.each do |r|
138
+ out.puts "#{r[:id].ljust(row_w)} #{plugins[r[:id]].ljust(plugin_w)} #{status_text(r)}"
139
+ end
140
+
141
+ info = info_lines(resolved, load_failures)
142
+ return if info.empty?
143
+
144
+ out.puts
145
+ info.each { |line| out.puts "info #{Composition.one_line(line)}" }
146
+ end
147
+
148
+ def self.status_text(result)
149
+ detail = Composition.one_line(result[:detail].to_s)
150
+ text = case result[:status]
151
+ when :ok then "ok"
152
+ when :unschema then "unschema'd"
153
+ when :warn then "warn: #{detail}"
154
+ when :error then "error: #{detail}"
155
+ end
156
+ result[:disabled] ? "#{text} (disabled)" : text
157
+ end
158
+
159
+ # Informational, never a verdict: which !env markers this composition reads
160
+ # and whether each resolves, plus any code that would not load. The resolved
161
+ # value of a marker never appears — a doctor that printed a secret would be
162
+ # one nobody could run in front of other people.
163
+ def self.info_lines(resolved, load_failures)
164
+ lines = env_markers(resolved.rows).map { |name| "#{name}: #{ENV.key?(name) ? 'set' : 'unset'}" }
165
+ load_failures.each { |file, message| lines << "could not require #{file.inspect}: #{message}" }
166
+ lines
167
+ end
168
+
169
+ def self.env_markers(rows)
170
+ names = []
171
+ rows.each { |row| collect_env(row.config, names) }
172
+ names.uniq
173
+ end
174
+
175
+ def self.collect_env(value, names)
176
+ case value
177
+ when Composition::Tagged then names << value.argument if value.tag == "env"
178
+ when Hash then value.each_value { |v| collect_env(v, names) }
179
+ when Array then value.each { |v| collect_env(v, names) }
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ # Terret home: where profiles live (docs/composition.md §3).
5
+ #
6
+ # ~/.terret/
7
+ # ├── patch.yml # applies to every profile
8
+ # └── profiles/<name>/{profile.yml,patch.yml}
9
+ #
10
+ # TERRET_HOME overrides ~/.terret wholesale, which is what makes the layer
11
+ # stack testable — every composition test points it at a tmpdir — and what
12
+ # lets a deployment ship a home directory as an artifact rather than as
13
+ # instructions for populating a user's dotfiles.
14
+ #
15
+ # A home that does not hold the named profile falls back to the templates
16
+ # this gem ships (gems/terret/profiles), so `trt boot --profile headless`
17
+ # works on a machine whose home is empty. The home always wins where it has
18
+ # an opinion; the shipped templates are only the floor.
19
+ class Home
20
+ DEFAULT = "~/.terret"
21
+ SHIPPED = File.expand_path("../../profiles", __dir__)
22
+
23
+ # ENV is read here rather than at load time so a test can set TERRET_HOME
24
+ # after this file is required.
25
+ def self.resolve(path = nil)
26
+ return path if path.is_a?(Home)
27
+
28
+ env = ENV["TERRET_HOME"]
29
+ env = nil if env.nil? || env.empty?
30
+ new(path || env || DEFAULT)
31
+ end
32
+
33
+ attr_reader :path
34
+
35
+ def initialize(path)
36
+ @path = File.expand_path(path.to_s)
37
+ end
38
+
39
+ def patch = File.join(path, "patch.yml")
40
+ def profile_dir(name) = File.join(path, "profiles", name.to_s)
41
+ def profile_config(name) = File.join(profile_dir(name), "profile.yml")
42
+ def profile_patch(name) = File.join(profile_dir(name), "patch.yml")
43
+
44
+ def shipped_profile_dir(name) = File.join(SHIPPED, name.to_s)
45
+ def shipped_profile_config(name) = File.join(shipped_profile_dir(name), "profile.yml")
46
+ def shipped_profile_patch(name) = File.join(shipped_profile_dir(name), "patch.yml")
47
+
48
+ # The pair of files a profile resolves to, home first and the shipped
49
+ # template as the floor. Either may be nil; a profile with neither does
50
+ # not exist.
51
+ #
52
+ # The two files are found independently on purpose. A home holding only a
53
+ # patch.yml is an operator who edited the one file the template told them
54
+ # to edit, and tying the patch's fate to a sibling profile.yml would drop
55
+ # it in silence — including when what it drops is a tightening of policy.
56
+ def profile_files(name)
57
+ config = [profile_config(name), shipped_profile_config(name)].find { |f| File.file?(f) }
58
+ shipped = config && config == shipped_profile_config(name)
59
+ patch = [profile_patch(name), (shipped_profile_patch(name) if shipped)]
60
+ .compact.find { |f| File.file?(f) }
61
+ [config, patch]
62
+ end
63
+
64
+ # Profile names offered by this home and by the shipped templates.
65
+ def profile_names
66
+ [File.join(path, "profiles"), SHIPPED]
67
+ .flat_map { |d| Dir.glob(File.join(d, "*", "profile.yml")) }
68
+ .map { |f| File.basename(File.dirname(f)) }
69
+ .uniq.sort
70
+ end
71
+
72
+ # dump-config prints these, so they are home-relative rather than absolute:
73
+ # a command whose whole purpose is "show me my config" should be safe to
74
+ # paste into an issue, and an absolute path names the operator.
75
+ def label(file)
76
+ f = file.to_s
77
+ return f.delete_prefix("#{path}/") if f.start_with?("#{path}/")
78
+ return "terret:#{f.delete_prefix("#{SHIPPED}/")}" if f.start_with?("#{SHIPPED}/")
79
+
80
+ f
81
+ end
82
+
83
+ def to_s = path
84
+ end
85
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ # Every gem whose services declare a Hames::Schema, as require paths. This is
5
+ # the single source `rake config:catalog` and the schema tests both read, so a
6
+ # newly configurable gem is added in one place. Split across two lists, a gem
7
+ # added to only one would be silently absent from the catalog with CI green.
8
+ #
9
+ # It is the base bundle's own requires plus the interface/adapter gems that
10
+ # ship schemas but are not part of terret-base (ws, mcp, morph).
11
+ SCHEMA_GEMS = %w[
12
+ terret/store/sqlite
13
+ terret/openrouter
14
+ terret/exec
15
+ terret/tools_std
16
+ terret/sandbox/docker
17
+ terret/ws
18
+ terret/acp
19
+ terret/mcp
20
+ terret/morph
21
+ ].freeze
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ # The meta-gem's own version — what `trt --version` prints and what
5
+ # terret.gemspec reads, so the number lives in exactly one place.
6
+ #
7
+ # Deliberately NOT Terret::VERSION: terret-core already owns that constant
8
+ # and it is terret-core's number. The 0.1.0 lockstep release aligns the two
9
+ # numbers, but they stay distinct constants — quietly redefining a sibling
10
+ # gem's version constant is not a thing this gem gets to do.
11
+ module Meta
12
+ VERSION = "0.1.0"
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # Rows this profile changes about terret-base. Applied after every bundle in
2
+ # the stack, before ~/.terret/patch.yml and before any --patch overlay.
3
+ #
4
+ # A patch targeting an existing id replaces that row's config WHOLESALE. It
5
+ # never deep-merges. So a patch that repoints the model has to carry the api
6
+ # key along with it, and `trt dump-config --profile headless` is how you see
7
+ # what a row currently holds before you replace it.
8
+ #
9
+ # A row with an id that is not in the stack yet is an insertion, and it has to
10
+ # say where it goes with `before:` or `after:` naming an existing row.
11
+
12
+ rows: []
13
+ # ---------------------------------------------------------------------------
14
+ # UNCOMMENT TO RUN TOOLS DIRECTLY ON THIS HOST, UNSANDBOXED.
15
+ #
16
+ # Every Bash command, every spawned process, and every terminal this agent
17
+ # opens runs as you, on your machine, with your network. A prompt-injected
18
+ # instruction that reaches a tool call has nothing between it and the host but
19
+ # the allow list. See docs/security.md.
20
+ #
21
+ # This comment block is not a security control. What it is, is the difference
22
+ # between a decision made and a default inherited.
23
+ # ---------------------------------------------------------------------------
24
+ # rows:
25
+ # - id: sandbox
26
+ # plugin: Terret::Exec::SandboxNone
27
+ # config: {}
@@ -0,0 +1,57 @@
1
+ # The headless profile — the template Terret ships (docs/composition.md §3).
2
+ #
3
+ # Copy this directory to ~/.terret/profiles/<name>/ and edit it, or edit it in
4
+ # place under ~/.terret/profiles/headless/. A home that holds no copy falls
5
+ # back to this file, so `trt boot --profile headless` runs out of the box —
6
+ # which also means an unedited profile has no workspace and can read nothing.
7
+ #
8
+ # trt dump-config --profile headless # what the stack actually resolves to
9
+ # trt doctor --profile headless # what does not fit
10
+ # trt boot --profile headless # run it
11
+
12
+ bundles:
13
+ - terret # terret-base, always layer one
14
+
15
+ # Out-of-tree requires for code that is not a bundle. Bundles pull in their
16
+ # own gems; this is for a plugin you wrote and put on the load path yourself.
17
+ plugins: []
18
+
19
+ # The settings map has no schema of its own. Its whole job is to be the target
20
+ # of !setting references, so a value used by three rows is written once.
21
+ settings:
22
+ # EVERY FILESYSTEM TOOL IS CONTAINED TO THESE DIRECTORIES, and an empty list
23
+ # denies every file operation. That is the safe default and not a bug: there
24
+ # is no ungranted-but-permitted state. Add the absolute path of the project
25
+ # you want the agent to work in.
26
+ #
27
+ # workspace:
28
+ # - /Users/you/code/some-project
29
+ workspace: []
30
+
31
+ store:
32
+ # ------------------------------------------------------------------
33
+ # CHANGE THIS TO A DIRECTORY YOU OWN.
34
+ #
35
+ # The append-only session log. Everything the agent reads, says and
36
+ # does lands here in the clear — prompts, tool output, file contents.
37
+ # The default below is a placeholder that works on a laptop and is a
38
+ # poor choice anywhere else: /var/tmp is world-shared, and this exact
39
+ # path is predictable, so on a multi-user box another account can read
40
+ # your agent's whole history (and, if it gets there first, choose what
41
+ # your agent reads back). Point it inside your home or your Terret
42
+ # home, on a filesystem you can back up.
43
+ #
44
+ # Absolute paths only. A relative one resolves against whatever
45
+ # directory trt happened to be run from, which is rarely what you meant.
46
+ # ------------------------------------------------------------------
47
+ path: /var/tmp/terret/sessions.db
48
+
49
+ model:
50
+ # "provider/model". The provider is the adapter name a row registered;
51
+ # terret-base registers "openrouter", keyed from OPENROUTER_API_KEY.
52
+ main: openrouter/anthropic/claude-sonnet-4.5
53
+
54
+ sandbox:
55
+ # The container every spawned process runs inside. It needs whatever
56
+ # toolchain your agent's commands expect; the default is small on purpose.
57
+ image: ruby:slim
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: terret
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -23,16 +23,101 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.1'
26
- description: Placeholder release. Terret is a Ruby-native, model-agnostic agent harness
27
- where everything is a plugin. This meta-gem will carry the trt CLI, profiles, and
28
- boot; none of that is written yet. The working code is in terret-core and hames.
26
+ - !ruby/object:Gem::Dependency
27
+ name: terret-exec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: terret-openrouter
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: terret-sandbox-docker
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: terret-store-sqlite
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: terret-tools-std
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.1'
96
+ description: 'The meta-gem, and the way a Terret is composed. Bundles ship ordered
97
+ config rows, profiles stack bundles, patches adjust rows by id, and Terret.boot
98
+ hands the result to the Hames loader -- so which plugins run, in what order, with
99
+ what config is a question YAML answers rather than Ruby. Ships terret-base (the
100
+ log, the harness, the model seam, the execution world sandboxed with the network
101
+ denied, the standard tool roster, and a policy floor that starts closed), the headless
102
+ profile template, and trt: boot, dump-config, doctor.'
29
103
  email:
30
104
  - obiefernandez@gmail.com
31
- executables: []
105
+ executables:
106
+ - trt
32
107
  extensions: []
33
108
  extra_rdoc_files: []
34
109
  files:
110
+ - config/bundle.yml
111
+ - exe/trt
112
+ - lib/terret/boot.rb
35
113
  - lib/terret/cli.rb
114
+ - lib/terret/composition.rb
115
+ - lib/terret/doctor.rb
116
+ - lib/terret/home.rb
117
+ - lib/terret/schema_gems.rb
118
+ - lib/terret/version.rb
119
+ - profiles/headless/patch.yml
120
+ - profiles/headless/profile.yml
36
121
  homepage: https://terret.org
37
122
  licenses:
38
123
  - MIT
@@ -41,6 +126,7 @@ metadata:
41
126
  source_code_uri: https://github.com/terret-org/terret
42
127
  bug_tracker_uri: https://github.com/terret-org/terret/issues
43
128
  rubygems_mfa_required: 'true'
129
+ terret: config/bundle.yml
44
130
  rdoc_options: []
45
131
  require_paths:
46
132
  - lib
@@ -57,5 +143,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
143
  requirements: []
58
144
  rubygems_version: 4.0.16
59
145
  specification_version: 4
60
- summary: Ruby-native, model-agnostic agent harness (name placeholder)
146
+ summary: 'Ruby-native, model-agnostic agent harness: profiles, boot, and the trt CLI'
61
147
  test_files: []