my-ruby-coding-agents 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +178 -0
  5. data/doc/CHANGELOG.md +33 -0
  6. data/doc/MyRubyCodingAgents/Agent.md +56 -0
  7. data/doc/MyRubyCodingAgents/CLI.md +20 -0
  8. data/doc/MyRubyCodingAgents/Generated.md +30 -0
  9. data/doc/MyRubyCodingAgents/Installer.md +39 -0
  10. data/doc/MyRubyCodingAgents/InstructionFile.md +30 -0
  11. data/doc/MyRubyCodingAgents/Models/Tier.md +33 -0
  12. data/doc/MyRubyCodingAgents/Models.md +38 -0
  13. data/doc/MyRubyCodingAgents/Planner/Entry.md +31 -0
  14. data/doc/MyRubyCodingAgents/Planner.md +21 -0
  15. data/doc/MyRubyCodingAgents/Renderers.md +15 -0
  16. data/doc/MyRubyCodingAgents/Templates.md +35 -0
  17. data/doc/MyRubyCodingAgents.md +25 -0
  18. data/doc/README.md +178 -0
  19. data/exe/my-ruby-coding-agents +6 -0
  20. data/lib/my_ruby_coding_agents/agent.rb +65 -0
  21. data/lib/my_ruby_coding_agents/cli.rb +129 -0
  22. data/lib/my_ruby_coding_agents/generated.rb +49 -0
  23. data/lib/my_ruby_coding_agents/installer.rb +68 -0
  24. data/lib/my_ruby_coding_agents/instruction_file.rb +31 -0
  25. data/lib/my_ruby_coding_agents/models.rb +95 -0
  26. data/lib/my_ruby_coding_agents/planner.rb +78 -0
  27. data/lib/my_ruby_coding_agents/renderers.rb +43 -0
  28. data/lib/my_ruby_coding_agents/templates.rb +30 -0
  29. data/lib/my_ruby_coding_agents/version.rb +6 -0
  30. data/lib/my_ruby_coding_agents.rb +12 -0
  31. data/llms.txt +25 -0
  32. data/templates/agents/api_cli_implementer.md +8 -0
  33. data/templates/agents/code_archaeologist.md +11 -0
  34. data/templates/agents/contract_mapper.md +9 -0
  35. data/templates/agents/evidence_reviewer.md +9 -0
  36. data/templates/agents/pr_readiness_checker.md +11 -0
  37. data/templates/agents/quality_investigator.md +9 -0
  38. data/templates/agents/rails_explorer.md +9 -0
  39. data/templates/agents/rails_implementer.md +8 -0
  40. data/templates/agents/security_mapper.md +9 -0
  41. data/templates/agents/security_reviewer.md +11 -0
  42. data/templates/agents/test_designer.md +9 -0
  43. data/templates/agents/ui_implementer.md +8 -0
  44. data/templates/instructions.md +30 -0
  45. data/templates/models.yml +50 -0
  46. metadata +90 -0
data/doc/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # my-ruby-coding-agents
2
+
3
+ Install a shared set of Ruby and Rails coding agents into a project for Codex and
4
+ Claude Code. The gem keeps each agent's instructions in one Markdown template
5
+ and renders the tool-specific files for you.
6
+
7
+ The installer also adds a short delegation guide to `AGENTS.md` and
8
+ `CLAUDE.md`. It uses marker comments so later runs update the guide instead of
9
+ adding duplicates.
10
+
11
+ ## Installation
12
+
13
+ Requires Ruby 4.0 or newer.
14
+
15
+ ```sh
16
+ gem install my-ruby-coding-agents
17
+ ```
18
+
19
+ Or add it to a bundle:
20
+
21
+ ```sh
22
+ bundle add my-ruby-coding-agents
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Install both Codex and Claude Code agents into the current directory:
28
+
29
+ ```sh
30
+ my-ruby-coding-agents install
31
+ ```
32
+
33
+ Pass a project directory, choose one target, or preview the changes:
34
+
35
+ ```sh
36
+ my-ruby-coding-agents install ./my-app --codex
37
+ my-ruby-coding-agents install ./my-app --claude
38
+ my-ruby-coding-agents install ./my-app --dry-run
39
+ ```
40
+
41
+ `install` writes `.codex/agents/*.toml` and `.claude/agents/*.md`, plus the
42
+ delegation section in `AGENTS.md` and `CLAUDE.md`. The directory defaults to
43
+ `.`. `--codex` and `--claude` limit which tool's files are installed; with
44
+ neither flag, both targets are installed.
45
+
46
+ ### Refreshing after a gem upgrade
47
+
48
+ Each generated agent file has a marker line with a hash of its content: a
49
+ `# my-ruby-coding-agents: <hash>` comment on the first line of the TOML file,
50
+ and an `<!-- my-ruby-coding-agents: <hash> -->` comment right after the
51
+ frontmatter of the Markdown file. Codex and Claude Code ignore these comments.
52
+ The installer uses the hash to tell a file nobody edited from an edited one.
53
+
54
+ Running `install` again applies these rules to each agent file:
55
+
56
+ | File state | Action |
57
+ | --- | --- |
58
+ | Missing | `write` |
59
+ | Generated, not edited, same as the gem | `skip ... (up to date)` |
60
+ | Generated, not edited, gem has a newer version | `update` |
61
+ | Edited by hand, or no marker | `skip ... (edited; use --force to overwrite)` |
62
+
63
+ So after a gem upgrade, a plain `install` refreshes every agent file you did
64
+ not edit and keeps your edits. Use `--force` to overwrite all agent files,
65
+ including edited ones:
66
+
67
+ ```sh
68
+ my-ruby-coding-agents install ./my-app --force
69
+ ```
70
+
71
+ Agent files installed by a version without markers count as edited. Run
72
+ `install --force` once to replace them with marked files.
73
+
74
+ `--dry-run` prints the same lines with a `would ` prefix (`would write`,
75
+ `would update`, `would overwrite`) and writes nothing.
76
+
77
+ The delegation section in `AGENTS.md` and `CLAUDE.md` sits between marker
78
+ comments. `install` adds it when missing and replaces only that block when it
79
+ is out of date; the rest of the file stays as is.
80
+
81
+ ### Checking a project
82
+
83
+ `status` prints one line per file that `install` would manage, with its state:
84
+
85
+ ```sh
86
+ my-ruby-coding-agents status ./my-app
87
+ my-ruby-coding-agents status ./my-app --codex
88
+ ```
89
+
90
+ ```text
91
+ up to date .codex/agents/rails_explorer.toml
92
+ outdated .codex/agents/test_designer.toml
93
+ edited .claude/agents/rails_explorer.md
94
+ missing .claude/agents/ui_implementer.md
95
+ missing block CLAUDE.md
96
+ ```
97
+
98
+ Agent files are `missing`, `up to date`, `outdated` (not edited, but the gem
99
+ has a newer version), or `edited` (edited by hand or without a marker).
100
+ `AGENTS.md` and `CLAUDE.md` are `missing block`, `up to date`, or `outdated`.
101
+ `status` exits with 0 when everything is up to date and 1 otherwise, so you
102
+ can use it in CI.
103
+
104
+ ### CLAUDE.md that imports AGENTS.md
105
+
106
+ Claude Code reads a line `@AGENTS.md` in `CLAUDE.md` as an import of
107
+ `AGENTS.md`. When `CLAUDE.md` has a line that is exactly `@AGENTS.md`
108
+ (surrounding spaces are fine), the installer does not add the block to
109
+ `CLAUDE.md`, since Claude Code already gets it through the import:
110
+
111
+ - With both targets (or with `--codex` too), it writes the block to
112
+ `AGENTS.md` and reports `skip CLAUDE.md (imports AGENTS.md)`.
113
+ - With only `--claude`, it writes the block to `AGENTS.md` instead and
114
+ reports `write AGENTS.md (imported by CLAUDE.md)`.
115
+
116
+ A linked `CLAUDE.md` that points to `AGENTS.md` is also written only once.
117
+
118
+ List the included agents with their tier, models, and efforts:
119
+
120
+ ```sh
121
+ my-ruby-coding-agents list
122
+ ```
123
+
124
+ Each line shows the agent name, tier, Codex model and effort, Claude model and
125
+ effort, and description:
126
+
127
+ ```text
128
+ rails_explorer | fast | Codex: gpt-6-luna (low) | Claude: sonnet (low) | Trace a Ruby workflow ...
129
+ ```
130
+
131
+ The twelve agents are `rails_explorer`, `contract_mapper`, `test_designer`,
132
+ `rails_implementer`, `api_cli_implementer`, `evidence_reviewer`,
133
+ `quality_investigator`, `ui_implementer`, `security_mapper`,
134
+ `security_reviewer`, `code_archaeologist`, and `pr_readiness_checker`.
135
+
136
+ `code_archaeologist` researches how and why part of the code works and writes
137
+ what it finds into `docs/archaeology/`, one note per topic plus an index. On
138
+ later runs it re-checks existing notes against the code and updates or marks
139
+ them stale instead of adding duplicates. Its tool list includes `Write` and
140
+ `Edit`, so the folder limit is an instruction, not a fence. Add a permission
141
+ rule in your tool's settings if you want to enforce it.
142
+
143
+ `pr_readiness_checker` uses the `gh` CLI to read open pull requests on a public
144
+ repository and reports which are ready to merge, which need changes from the
145
+ contributor, and which need a decision from you. It never merges, labels, or
146
+ posts comments. Run it from a checkout where `gh auth status` succeeds.
147
+
148
+ Use the generated delegation guide to choose a focused agent for independent
149
+ work. Handle small, coupled tasks yourself; parallelize independent bounded
150
+ tasks when the benefit outweighs coordination and token costs. Implementers
151
+ may add tests for their changes, with independent review for consequential
152
+ work. The names `rails_explorer` and `rails_implementer` also cover standalone
153
+ Ruby projects; the fleet keeps the same names across projects.
154
+
155
+ Models come from a tier table in `templates/models.yml`. Each tier (`fast`,
156
+ `investigate`, `implement`, `strong`, `review`) sets a model and reasoning effort for
157
+ Codex and for Claude, and each agent template names one tier with `tier:`.
158
+ Edit `templates/models.yml` to change routing; it is the one place model names
159
+ live. The installer builds the model sentence in the delegation guide from the
160
+ same table, so the guide, the agent files, and `list` always agree.
161
+
162
+ Model assignments are starting points, not benchmark-proven rankings. The lead
163
+ narrows ambiguous or unsuccessful tasks or uses a stronger available model;
164
+ there is no automatic model router.
165
+
166
+ ## Development
167
+
168
+ After checking out the repository, run `bin/setup`, then `bundle exec rake` to
169
+ run the tests and RuboCop. `bundle exec rake docs` regenerates the Markdown API
170
+ docs in `doc/` and `llms.txt`; both ship inside the gem, so commit them.
171
+ `bin/prepare_release` runs the whole gate and builds the gem into `pkg/`.
172
+ `bundle exec rake branchproof` runs the tests under
173
+ [Branchproof](https://github.com/lucianghinda/branchproof) and lists decisions
174
+ in `lib/` that the tests do not fully exercise. CI runs the same analysis.
175
+
176
+ ## License
177
+
178
+ Released under the MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "my_ruby_coding_agents"
5
+
6
+ exit(MyRubyCodingAgents::CLI.run(ARGV))
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MyRubyCodingAgents
6
+ # Parsed metadata and shared instruction body for one agent template.
7
+ # Models and efforts come from the agent's tier in the Models table.
8
+ class Agent
9
+ FIELDS = %w[name description tier tools].freeze
10
+ REQUIRED_FIELDS = %w[name description tier].freeze
11
+
12
+ attr_reader :name, :description, :tier, :tools, :body
13
+
14
+ # tier is a Models::Tier.
15
+ def initialize(name:, description:, tier:, tools:, body:)
16
+ @name = name
17
+ @description = description
18
+ @tier = tier
19
+ @tools = Array(tools).flat_map { |tool| tool.to_s.split(/\s*,\s*/) }.freeze if tools
20
+ @body = body.to_s.strip.freeze
21
+ freeze
22
+ end
23
+
24
+ def codex_model = tier.codex_model
25
+ def codex_effort = tier.codex_effort
26
+ def claude_model = tier.claude_model
27
+ def claude_effort = tier.claude_effort
28
+
29
+ def self.parse(source, models:)
30
+ metadata, body = split(source)
31
+ check_fields(metadata.keys.map(&:to_s))
32
+ new(name: metadata["name"], description: metadata["description"], tier: models.fetch(metadata["tier"]),
33
+ tools: metadata["tools"], body:)
34
+ end
35
+
36
+ # Returns the frontmatter mapping and the body text.
37
+ def self.split(source)
38
+ match = source.match(/\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)\z/m)
39
+ raise ArgumentError, "agent template must begin with YAML frontmatter" unless match
40
+
41
+ metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
42
+ raise ArgumentError, "agent frontmatter must be a mapping" unless metadata.is_a?(Hash)
43
+
44
+ [metadata, match[2]]
45
+ rescue Psych::Exception => e
46
+ raise ArgumentError, "invalid agent frontmatter: #{e.message}"
47
+ end
48
+ private_class_method :split
49
+
50
+ def self.check_fields(keys)
51
+ unknown = keys - FIELDS
52
+ raise ArgumentError, "unknown agent fields: #{unknown.join(", ")}" unless unknown.empty?
53
+
54
+ missing = REQUIRED_FIELDS - keys
55
+ raise ArgumentError, "missing agent fields: #{missing.join(", ")}" unless missing.empty?
56
+ end
57
+ private_class_method :check_fields
58
+
59
+ def self.load(path, models:)
60
+ parse(File.read(path), models:)
61
+ rescue ArgumentError => e
62
+ raise ArgumentError, "#{File.basename(path)}: #{e.message}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module MyRubyCodingAgents
6
+ # Handles the command line interface for installing, checking, and listing agents.
7
+ class CLI
8
+ def self.run(argv, out: $stdout, err: $stderr)
9
+ new(argv, out:, err:).run
10
+ end
11
+
12
+ def initialize(argv, out:, err:)
13
+ @argv = argv.dup
14
+ @out = out
15
+ @err = err
16
+ end
17
+
18
+ def run
19
+ dispatch(@argv.shift) || 0
20
+ rescue OptionParser::ParseError, ArgumentError => e
21
+ @err.puts(e.message)
22
+ 2
23
+ end
24
+
25
+ private
26
+
27
+ def dispatch(command)
28
+ case command
29
+ when "install" then install
30
+ when "status" then status
31
+ when "list" then list
32
+ when "--version", "-v" then @out.puts(MyRubyCodingAgents::VERSION)
33
+ when "--help", "-h", nil then @out.puts(help)
34
+ else unknown_command(command)
35
+ end
36
+ end
37
+
38
+ def unknown_command(command)
39
+ @err.puts("Unknown command: #{command}")
40
+ @err.puts(help)
41
+ 2
42
+ end
43
+
44
+ def install
45
+ options = parse_options("install")
46
+ return 0 if options[:help]
47
+
48
+ installer(options).install(force: options[:force], dry_run: options[:dry_run], out: @out)
49
+ 0
50
+ end
51
+
52
+ # Exit 0 when every planned file is up to date, 1 otherwise.
53
+ def status
54
+ options = parse_options("status")
55
+ return 0 if options[:help]
56
+
57
+ installer(options).status(out: @out) ? 0 : 1
58
+ end
59
+
60
+ def parse_options(command)
61
+ options = { targets: [], force: false, dry_run: false, help: false }
62
+ option_parser(command, options).parse!(@argv)
63
+ raise OptionParser::InvalidArgument, "expected at most one target directory" if @argv.length > 1
64
+
65
+ options[:targets] = %i[codex claude] if options[:targets].empty?
66
+ options
67
+ end
68
+
69
+ def option_parser(command, options)
70
+ OptionParser.new do |opts|
71
+ opts.banner = "Usage: my-ruby-coding-agents #{command} [DIR] [options]"
72
+ opts.on("--codex", "Use Codex agents and AGENTS.md") { options[:targets] << :codex }
73
+ opts.on("--claude", "Use Claude agents and CLAUDE.md") { options[:targets] << :claude }
74
+ add_install_options(opts, options) if command == "install"
75
+ opts.on("-h", "--help", "Show this help") { show_help(opts, options) }
76
+ end
77
+ end
78
+
79
+ def add_install_options(opts, options)
80
+ opts.on("--force", "Overwrite agent files, including edited ones") { options[:force] = true }
81
+ opts.on("--dry-run", "Show planned writes without changing files") { options[:dry_run] = true }
82
+ end
83
+
84
+ def show_help(opts, options)
85
+ @out.puts(opts)
86
+ options[:help] = true
87
+ end
88
+
89
+ def installer(options)
90
+ models = Templates.models
91
+ agents = Templates.agents(models)
92
+ Installer.new(dir: @argv.first || ".", agents:, instructions: Templates.instructions(models, agents),
93
+ targets: options[:targets])
94
+ end
95
+
96
+ def list
97
+ Templates.agents(Templates.models).each { |agent| @out.puts(list_line(agent)) }
98
+ 0
99
+ end
100
+
101
+ def list_line(agent)
102
+ [agent.name, agent.tier.name, "Codex: #{agent.codex_model} (#{agent.codex_effort})",
103
+ "Claude: #{agent.claude_model} (#{agent.claude_effort})", agent.description].join(" | ")
104
+ end
105
+
106
+ def help
107
+ <<~HELP
108
+ Usage: my-ruby-coding-agents <command> [options]
109
+
110
+ Commands:
111
+ install [DIR] Install or refresh agents and delegation instructions (default DIR: .)
112
+ status [DIR] Show which files are missing, outdated, edited, or up to date
113
+ list List bundled agents
114
+
115
+ Options:
116
+ --codex Use Codex files only
117
+ --claude Use Claude Code files only
118
+ --force install: overwrite agent files, including edited ones
119
+ --dry-run install: show planned writes without changing files
120
+ -v, --version Show version
121
+ -h, --help Show this help
122
+
123
+ install writes missing files and refreshes generated agent files nobody
124
+ edited. It skips edited agent files unless --force is given.
125
+ status exits 0 when everything is up to date and 1 otherwise.
126
+ HELP
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module MyRubyCodingAgents
6
+ # Marks rendered agent files with a hash of their content, so a later
7
+ # install can tell an untouched generated file from one a person edited.
8
+ module Generated
9
+ NAME = "my-ruby-coding-agents"
10
+ MARKER = /^(?:# #{NAME}: (?<hash>[0-9a-f]{16})|<!-- #{NAME}: (?<hash>[0-9a-f]{16}) -->)\r?\n/
11
+ FRONTMATTER = /\A---\r?\n.*?\r?\n---\r?\n/m
12
+
13
+ module_function
14
+
15
+ # Adds the marker line. style is :toml (first line) or :markdown
16
+ # (right after the frontmatter). The hash covers the content without
17
+ # the marker line.
18
+ def stamp(content, style:)
19
+ hash = digest(content)
20
+ case style
21
+ when :toml then "# #{NAME}: #{hash}\n#{content}"
22
+ when :markdown then stamp_markdown(content, hash)
23
+ else raise ArgumentError, "unknown marker style: #{style.inspect}"
24
+ end
25
+ end
26
+
27
+ # :unmodified when the marker hash matches the rest of the file,
28
+ # :modified when it does not, :foreign when there is no marker.
29
+ def status(existing_content)
30
+ match = existing_content.match(MARKER)
31
+ return :foreign unless match
32
+
33
+ rest = match.pre_match + match.post_match
34
+ match[:hash] == digest(rest) ? :unmodified : :modified
35
+ end
36
+
37
+ def digest(content)
38
+ Digest::SHA256.hexdigest(content)[0, 16]
39
+ end
40
+
41
+ def stamp_markdown(content, hash)
42
+ match = content.match(FRONTMATTER)
43
+ raise ArgumentError, "Markdown content must begin with frontmatter" unless match
44
+
45
+ "#{match[0]}<!-- #{NAME}: #{hash} -->\n#{match.post_match}"
46
+ end
47
+ private_class_method :stamp_markdown
48
+ end
49
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module MyRubyCodingAgents
6
+ # Writes planned files and reports the state of each one.
7
+ class Installer
8
+ START_MARKER = InstructionFile::START_MARKER
9
+ END_MARKER = InstructionFile::END_MARKER
10
+
11
+ WRITE_VERBS = { missing: "write", missing_block: "write", outdated: "update", edited: "overwrite" }.freeze
12
+ SKIP_REASONS = { up_to_date: "up to date", edited: "edited; use --force to overwrite",
13
+ imported: "imports AGENTS.md" }.freeze
14
+ STATUS_LABELS = { missing: "missing", missing_block: "missing block", up_to_date: "up to date",
15
+ outdated: "outdated", edited: "edited", imported: "skipped" }.freeze
16
+ CLEAN_STATES = %i[up_to_date imported].freeze
17
+
18
+ def initialize(dir:, agents:, instructions:, targets:)
19
+ @root = File.expand_path(dir)
20
+ @planner = Planner.new(root: @root, agents:, instructions:, targets:)
21
+ end
22
+
23
+ # Writes missing and outdated files. Edited agent files are kept unless
24
+ # force is true. Returns the number of planned files.
25
+ def install(out:, force: false, dry_run: false)
26
+ entries = @planner.entries
27
+ entries.each { |entry| apply(entry, force:, dry_run:, out:) }
28
+ entries.length
29
+ end
30
+
31
+ # Prints one state line per planned file. Returns true when nothing needs
32
+ # an install.
33
+ def status(out:)
34
+ entries = @planner.entries
35
+ entries.each { |entry| out.puts("#{STATUS_LABELS.fetch(entry.state).ljust(13)} #{describe(entry)}") }
36
+ entries.all? { |entry| CLEAN_STATES.include?(entry.state) }
37
+ end
38
+
39
+ private
40
+
41
+ def apply(entry, force:, dry_run:, out:)
42
+ if skip?(entry, force)
43
+ out.puts("skip #{relative(entry.path)} (#{SKIP_REASONS.fetch(entry.state)})")
44
+ return
45
+ end
46
+
47
+ out.puts("#{"would " if dry_run}#{WRITE_VERBS.fetch(entry.state)} #{describe(entry)}")
48
+ write(entry) unless dry_run
49
+ end
50
+
51
+ def skip?(entry, force)
52
+ CLEAN_STATES.include?(entry.state) || (entry.state == :edited && !force)
53
+ end
54
+
55
+ def write(entry)
56
+ FileUtils.mkdir_p(File.dirname(entry.path))
57
+ File.write(entry.path, entry.content)
58
+ end
59
+
60
+ def describe(entry)
61
+ entry.note ? "#{relative(entry.path)} (#{entry.note})" : relative(entry.path)
62
+ end
63
+
64
+ def relative(path)
65
+ path.delete_prefix("#{@root}/")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyRubyCodingAgents
4
+ # Builds AGENTS.md and CLAUDE.md content with the managed delegation block.
5
+ module InstructionFile
6
+ START_MARKER = "<!-- my-ruby-coding-agents:start -->"
7
+ END_MARKER = "<!-- my-ruby-coding-agents:end -->"
8
+ BLOCK = /#{Regexp.escape(START_MARKER)}.*?#{Regexp.escape(END_MARKER)}/m
9
+ # A CLAUDE.md line that imports AGENTS.md in Claude Code.
10
+ IMPORT = /^[ \t]*@AGENTS\.md[ \t]*\r?$/
11
+
12
+ module_function
13
+
14
+ # Replaces the block in existing content, or appends it. existing may be nil.
15
+ def render(existing, instructions)
16
+ block = "#{START_MARKER}\n#{instructions.to_s.strip}\n#{END_MARKER}"
17
+ existing = existing.to_s
18
+ return existing.sub(BLOCK) { block } if block?(existing)
19
+
20
+ "#{[existing.rstrip, block].reject(&:empty?).join("\n\n")}\n"
21
+ end
22
+
23
+ def block?(content)
24
+ content.to_s.match?(BLOCK)
25
+ end
26
+
27
+ def imports_agents?(path)
28
+ File.file?(path) && File.read(path).match?(IMPORT)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MyRubyCodingAgents
6
+ # Model tier table loaded from models.yml. Maps a tier name to the model
7
+ # and reasoning effort each tool uses.
8
+ class Models
9
+ TIER_KEYS = %w[summary codex claude].freeze
10
+ TOOL_KEYS = %w[model effort].freeze
11
+
12
+ # One resolved tier.
13
+ Tier = Data.define(:name, :summary, :codex_model, :codex_effort, :claude_model, :claude_effort)
14
+
15
+ def self.load(path)
16
+ parse(File.read(path))
17
+ end
18
+
19
+ def self.parse(source)
20
+ new(YAML.safe_load(source, permitted_classes: [], aliases: false))
21
+ rescue Psych::Exception => e
22
+ raise ArgumentError, "invalid models.yml: #{e.message}"
23
+ end
24
+
25
+ def initialize(table)
26
+ raise ArgumentError, "models.yml must map tier names to settings" unless table.is_a?(Hash) && !table.empty?
27
+
28
+ @tiers = table.to_h { |name, settings| [name.to_s, build_tier(name.to_s, settings)] }.freeze
29
+ freeze
30
+ end
31
+
32
+ # Tier names in file order.
33
+ def names
34
+ @tiers.keys
35
+ end
36
+
37
+ def fetch(name)
38
+ @tiers.fetch(name.to_s) do
39
+ raise ArgumentError, "unknown model tier: #{name.inspect} (known tiers: #{names.join(", ")})"
40
+ end
41
+ end
42
+
43
+ # One sentence per used tier: its purpose, models, and agents.
44
+ # Tiers keep file order and agents are sorted by name, so output is stable.
45
+ def routing(agents)
46
+ groups = agents.group_by { |agent| agent.tier.name }
47
+ parts = names.filter_map do |name|
48
+ next unless groups.key?(name)
49
+
50
+ "#{describe(fetch(name))}: #{groups[name].map { |agent| "`#{agent.name}`" }.sort.join(", ")}."
51
+ end
52
+ "Model tiers: #{parts.join(" ")}"
53
+ end
54
+
55
+ private
56
+
57
+ def describe(tier)
58
+ "`#{tier.name}` for #{tier.summary} (Codex #{tier.codex_model} #{tier.codex_effort}, " \
59
+ "Claude #{tier.claude_model} #{tier.claude_effort})"
60
+ end
61
+
62
+ def build_tier(name, settings)
63
+ check_keys(settings, TIER_KEYS, "tier #{name}")
64
+ check_string(settings["summary"], "tier #{name} summary")
65
+ codex = tool_settings(name, settings, "codex")
66
+ claude = tool_settings(name, settings, "claude")
67
+ Tier.new(name:, summary: settings["summary"].strip,
68
+ codex_model: codex["model"], codex_effort: codex["effort"],
69
+ claude_model: claude["model"], claude_effort: claude["effort"])
70
+ end
71
+
72
+ def tool_settings(name, settings, tool)
73
+ value = settings[tool]
74
+ check_keys(value, TOOL_KEYS, "tier #{name} #{tool}")
75
+ TOOL_KEYS.each { |key| check_string(value[key], "tier #{name} #{tool} #{key}") }
76
+ value
77
+ end
78
+
79
+ def check_keys(value, keys, label)
80
+ raise ArgumentError, "#{label} must be a mapping" unless value.is_a?(Hash)
81
+
82
+ unknown = value.keys.map(&:to_s) - keys
83
+ raise ArgumentError, "unknown keys in #{label}: #{unknown.join(", ")}" unless unknown.empty?
84
+
85
+ missing = keys - value.keys
86
+ raise ArgumentError, "missing keys in #{label}: #{missing.join(", ")}" unless missing.empty?
87
+ end
88
+
89
+ def check_string(value, label)
90
+ return if value.is_a?(String) && !value.strip.empty?
91
+
92
+ raise ArgumentError, "#{label} must be a non-empty string"
93
+ end
94
+ end
95
+ end