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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/doc/CHANGELOG.md +33 -0
- data/doc/MyRubyCodingAgents/Agent.md +56 -0
- data/doc/MyRubyCodingAgents/CLI.md +20 -0
- data/doc/MyRubyCodingAgents/Generated.md +30 -0
- data/doc/MyRubyCodingAgents/Installer.md +39 -0
- data/doc/MyRubyCodingAgents/InstructionFile.md +30 -0
- data/doc/MyRubyCodingAgents/Models/Tier.md +33 -0
- data/doc/MyRubyCodingAgents/Models.md +38 -0
- data/doc/MyRubyCodingAgents/Planner/Entry.md +31 -0
- data/doc/MyRubyCodingAgents/Planner.md +21 -0
- data/doc/MyRubyCodingAgents/Renderers.md +15 -0
- data/doc/MyRubyCodingAgents/Templates.md +35 -0
- data/doc/MyRubyCodingAgents.md +25 -0
- data/doc/README.md +178 -0
- data/exe/my-ruby-coding-agents +6 -0
- data/lib/my_ruby_coding_agents/agent.rb +65 -0
- data/lib/my_ruby_coding_agents/cli.rb +129 -0
- data/lib/my_ruby_coding_agents/generated.rb +49 -0
- data/lib/my_ruby_coding_agents/installer.rb +68 -0
- data/lib/my_ruby_coding_agents/instruction_file.rb +31 -0
- data/lib/my_ruby_coding_agents/models.rb +95 -0
- data/lib/my_ruby_coding_agents/planner.rb +78 -0
- data/lib/my_ruby_coding_agents/renderers.rb +43 -0
- data/lib/my_ruby_coding_agents/templates.rb +30 -0
- data/lib/my_ruby_coding_agents/version.rb +6 -0
- data/lib/my_ruby_coding_agents.rb +12 -0
- data/llms.txt +25 -0
- data/templates/agents/api_cli_implementer.md +8 -0
- data/templates/agents/code_archaeologist.md +11 -0
- data/templates/agents/contract_mapper.md +9 -0
- data/templates/agents/evidence_reviewer.md +9 -0
- data/templates/agents/pr_readiness_checker.md +11 -0
- data/templates/agents/quality_investigator.md +9 -0
- data/templates/agents/rails_explorer.md +9 -0
- data/templates/agents/rails_implementer.md +8 -0
- data/templates/agents/security_mapper.md +9 -0
- data/templates/agents/security_reviewer.md +11 -0
- data/templates/agents/test_designer.md +9 -0
- data/templates/agents/ui_implementer.md +8 -0
- data/templates/instructions.md +30 -0
- data/templates/models.yml +50 -0
- 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,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
|