roast-ai 1.1.0 → 1.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +1 -1
  3. data/.rubocop.yml +10 -1
  4. data/Gemfile.lock +123 -18
  5. data/README.md +56 -3
  6. data/examples/custom_logging.rb +4 -2
  7. data/examples/demo/Gemfile.lock +17 -15
  8. data/examples/plugin-gem-example/Gemfile.lock +15 -15
  9. data/examples/simple_chat.rb +1 -1
  10. data/internal/rubocop/cop/roast/no_test_class_nesting.rb +126 -0
  11. data/internal/rubocop/rubocop-roast.yml +6 -0
  12. data/internal/workflows/maintenance/branch_docs_impact.rb +97 -0
  13. data/internal/workflows/maintenance/deprecated_models_docs_updater.rb +78 -0
  14. data/lib/roast/cog/config.rb +1 -1
  15. data/lib/roast/cog_input_manager.rb +28 -7
  16. data/lib/roast/cogs/agent/config.rb +1 -1
  17. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +2 -2
  18. data/lib/roast/cogs/agent/providers/claude/messages/result_message.rb +1 -1
  19. data/lib/roast/cogs/agent/providers/claude/tool_result.rb +344 -4
  20. data/lib/roast/cogs/agent/providers/claude/tool_use.rb +356 -1
  21. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +2 -2
  22. data/lib/roast/cogs/agent.rb +3 -2
  23. data/lib/roast/cogs/chat/config.rb +28 -2
  24. data/lib/roast/cogs/chat.rb +82 -10
  25. data/lib/roast/event.rb +1 -0
  26. data/lib/roast/event_monitor.rb +35 -3
  27. data/lib/roast/log.rb +21 -0
  28. data/lib/roast/log_formatter.rb +9 -7
  29. data/lib/roast/version.rb +1 -1
  30. data/roast-ai.gemspec +1 -1
  31. data/tutorial/01_your_first_workflow/README.md +9 -5
  32. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  33. data/tutorial/02_chaining_cogs/README.md +2 -2
  34. data/tutorial/02_chaining_cogs/code_review.rb +1 -1
  35. data/tutorial/02_chaining_cogs/session_resumption.rb +1 -1
  36. data/tutorial/03_targets_and_params/README.md +1 -1
  37. data/tutorial/04_configuration_options/README.md +2 -2
  38. data/tutorial/08_iterative_workflows/README.md +1 -1
  39. data/tutorial/README.md +1 -1
  40. metadata +12 -8
  41. /data/internal/documentation/{architectural-notes.md → comments/architectural-notes.md} +0 -0
  42. /data/internal/documentation/{doc-comments-external.md → comments/doc-comments-external.md} +0 -0
  43. /data/internal/documentation/{doc-comments-internal.md → comments/doc-comments-internal.md} +0 -0
  44. /data/internal/documentation/{doc-comments.md → comments/doc-comments.md} +0 -0
@@ -0,0 +1,126 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module RuboCop
5
+ module Cop
6
+ module Roast
7
+ # Prevents nesting classes or modules inside reopened (non-test) class
8
+ # definitions in test files.
9
+ #
10
+ # When a class is reopened in a test file (e.g., `class Agent < Cog`) and
11
+ # contains nested class or module definitions, IDE test runners like
12
+ # RubyMine fail to discover test suites. Use `::` scoping instead.
13
+ #
14
+ # The cop walks the entire subtree of the offending class, so deeply
15
+ # nested structures (e.g., `class Agent < Cog; module Providers; ...`)
16
+ # are caught even when the nested definitions are not direct children.
17
+ #
18
+ # Classes nested inside test classes are exempt — helper stubs and
19
+ # fixtures defined inside a test suite are perfectly fine.
20
+ #
21
+ # @example Bad — reopened class with nested module
22
+ # class Agent < Cog
23
+ # module Providers
24
+ # class Claude::MessageTest < ActiveSupport::TestCase
25
+ # # ...
26
+ # end
27
+ # end
28
+ # end
29
+ #
30
+ # @example Bad — reopened class with nested test class
31
+ # class Agent < Cog
32
+ # class ConfigTest < ActiveSupport::TestCase
33
+ # # ...
34
+ # end
35
+ # end
36
+ #
37
+ # @example Good — :: scoping, no class reopening
38
+ # module Agent::Providers
39
+ # class Claude::MessageTest < ActiveSupport::TestCase
40
+ # # ...
41
+ # end
42
+ # end
43
+ #
44
+ # @example Good — :: scoped test class
45
+ # class Agent::ConfigTest < ActiveSupport::TestCase
46
+ # # ...
47
+ # end
48
+ #
49
+ # @example Good — helper class inside a test class
50
+ # class Agent::OutputTest < ActiveSupport::TestCase
51
+ # class FakeAdapter
52
+ # def call; end
53
+ # end
54
+ # end
55
+ #
56
+ class NoTestClassNesting < Base
57
+ MSG = "Do not nest classes or modules inside reopened class `%<parent>s` in test files. " \
58
+ "Use `::` scoping instead (e.g., `class %<parent>s::Nested` or `module %<parent>s::Nested`)."
59
+
60
+ # @!method test_base_class?(node)
61
+ def_node_matcher :test_base_class?, <<~PATTERN
62
+ {
63
+ (const (const {nil? cbase} :ActiveSupport) :TestCase)
64
+ (const (const {nil? cbase} :Minitest) :Test)
65
+ (const {nil? cbase} :Minitest)
66
+ }
67
+ PATTERN
68
+
69
+ def on_class(node)
70
+ # Test classes are allowed to contain nested definitions (helpers, stubs)
71
+ return if test_class?(node)
72
+
73
+ # Classes nested inside a test class are helpers — leave them alone
74
+ return if inside_test_class?(node)
75
+
76
+ # Flag if this non-test class contains any nested class or module
77
+ return unless contains_nested_definitions?(node)
78
+
79
+ message = format(MSG, parent: node.identifier.const_name)
80
+ add_offense(node.loc.keyword.join(node.identifier.source_range), message: message)
81
+ end
82
+
83
+ private
84
+
85
+ def test_class?(node)
86
+ node.parent_class && test_base_class?(node.parent_class)
87
+ end
88
+
89
+ # Returns true if any ancestor of +node+ is a test class.
90
+ def inside_test_class?(node)
91
+ current = node.parent
92
+ while current
93
+ return true if current.class_type? && test_class?(current)
94
+
95
+ current = current.parent
96
+ end
97
+ false
98
+ end
99
+
100
+ # Returns true if +node+ contains any nested class or module definition
101
+ # at any depth, excluding those sheltered inside an intermediate test class.
102
+ def contains_nested_definitions?(node)
103
+ node.each_descendant(:class, :module) do |descendant|
104
+ next if sheltered_by_test_class?(descendant, node)
105
+
106
+ return true
107
+ end
108
+ false
109
+ end
110
+
111
+ # Returns true if there is a test class between +descendant+ and +stop_at+
112
+ # in the ancestor chain — meaning the descendant is a helper inside a test
113
+ # class and should not count as a problematic nested definition.
114
+ def sheltered_by_test_class?(descendant, stop_at)
115
+ current = descendant.parent
116
+ while current && current != stop_at
117
+ return true if current.class_type? && test_class?(current)
118
+
119
+ current = current.parent
120
+ end
121
+ false
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,6 @@
1
+ Roast/NoTestClassNesting:
2
+ Description: "Prevents nesting test classes inside reopened production classes. Use :: scoping instead."
3
+ Enabled: true
4
+ VersionAdded: "0.1.0"
5
+ Include:
6
+ - "test/**/*.rb"
@@ -0,0 +1,97 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # Gets the committed diff of the current branch vs origin/main, then analyzes it for potential
7
+ # documentation impacts, and optionally applies fixes. This is meant to be run as a pre-merge check, to
8
+ # catch any potential documentation issues before they get merged into main.
9
+ #
10
+ # Accepts a `--fix` flag to apply any suggested fixes in place. Otherwise, it just reports the analysis
11
+ # and recommended fixes without applying them.
12
+
13
+ config do
14
+ agent do
15
+ provider :claude
16
+ model "claude-opus-4-7"
17
+ quiet!
18
+ end
19
+ chat do
20
+ provider :openai
21
+ model "gpt-5"
22
+ quiet!
23
+ end
24
+ end
25
+
26
+ execute do
27
+ cmd(:diff) do
28
+ merge_base = %x(git merge-base origin/main HEAD).strip
29
+ fail!("could not determine merge-base with origin/main — run `git fetch origin main` first") if merge_base.empty?
30
+ "git diff #{merge_base} HEAD"
31
+ end
32
+
33
+ agent(:analyzer) do
34
+ skip! if cmd!(:diff).text.strip.empty?
35
+ fail!("diff too large (#{cmd!(:diff).text.bytesize} bytes) to analyze — narrow the branch or exclude generated files") if cmd!(:diff).text.bytesize > 500_000
36
+ <<~PROMPT
37
+ You are checking whether a git diff makes any existing documentation stale.
38
+
39
+ Rules:
40
+ - Use ONLY the diff below. Do not run any commands.
41
+ - Be thorough about finding real issues — do not be conservative.
42
+ - But do NOT speculate about docs you cannot see in the diff.
43
+ - No markdown headers, no preamble, no caveats, no "limitations" sections.
44
+
45
+ Output format — pick exactly one:
46
+
47
+ If nothing in the diff affects existing docs, output a single line:
48
+ No documentation impact.
49
+
50
+ Otherwise, output one block per affected doc, separated by blank lines:
51
+ <doc/path.md>
52
+ Stale because: <one sentence>
53
+ Fix: <one sentence>
54
+
55
+ --- DIFF START ---
56
+ #{cmd!(:diff).text}
57
+ --- DIFF END ---
58
+ PROMPT
59
+ end
60
+
61
+ chat(:report) do
62
+ skip! if cmd!(:diff).text.strip.empty?
63
+ <<~PROMPT
64
+ Based on the following analysis, summarize the impact of the changes in this branch on the project's documentation.
65
+ Highlight any significant improvements or regressions, and provide recommendations for any additional documentation updates that may be necessary.
66
+ Do not suggest updates that are not needed.
67
+ Do not be verbose. Be super concise.
68
+
69
+ Analysis:
70
+ #{agent!(:analyzer).response}
71
+ PROMPT
72
+ end
73
+
74
+ agent(:fixer) do
75
+ skip! unless arg?(:fix)
76
+ skip! if cmd!(:diff).text.strip.empty?
77
+ skip! if agent!(:analyzer).response.strip == "No documentation impact."
78
+ <<~PROMPT
79
+ Apply the documentation fixes suggested in the analysis below. Edit the
80
+ affected files in place. Do not modify any code files; docs only.
81
+
82
+ #{agent!(:analyzer).response}
83
+ PROMPT
84
+ end
85
+
86
+ ruby(:output) do
87
+ if cmd!(:diff).text.strip.empty?
88
+ puts "No changes vs origin/main — nothing to analyze."
89
+ else
90
+ files = cmd!(:diff).out.scan(%r{^diff --git a/.+ b/(.+)$}).flatten
91
+ puts "Files considered (#{files.size}):"
92
+ files.each { |f| puts " #{f}" }
93
+ puts "ANALYSIS:\n#{chat!(:report).response}"
94
+ puts(agent?(:fixer) ? "Fixes applied." : "(Next time, run with `-- fix` to auto-apply suggested edits)")
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,78 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # Looks at the Anthropic (https://platform.claude.com/docs/en/about-claude/model-deprecations) and
7
+ # OpenAI (https://developers.openai.com/api/docs/deprecations) model-deprecation pages and updates all
8
+ # docs, doc comments and code references to deprecated/retired models to reflect those changes. This is
9
+ # meant to be run periodically to keep all the references to models up to date.
10
+
11
+ config do
12
+ agent do
13
+ provider :claude
14
+ model "claude-opus-4-8"
15
+ quiet!
16
+ end
17
+ end
18
+
19
+ execute do
20
+ agent(:model_verifier) do
21
+ <<~PROMPT
22
+ Check both of these pages for deprecated/retired models and their suggested replacements:
23
+ - Anthropic (Claude): https://platform.claude.com/docs/en/about-claude/model-deprecations
24
+ - OpenAI: https://developers.openai.com/api/docs/deprecations
25
+
26
+ Include every deprecated or retired model that has a recommended replacement, from both pages.
27
+ Only include models — ignore deprecated API endpoints, tools, or other non-model features.
28
+
29
+ Output ONLY a JSON object of exactly this shape, with no surrounding prose:
30
+ {"outdated": [{"model": "<outdated_model>", "replacement": "<replacement_model>"}]}
31
+
32
+ If there are none, output {"outdated": []}.
33
+ PROMPT
34
+ end
35
+
36
+ agent(:model_finder) do
37
+ <<~PROMPT
38
+ You are a code search engine. Search through the codebase and documentation for any references to
39
+ the outdated models below, including test files, and list every place where they are mentioned.
40
+
41
+ The input is a JSON object of the form:
42
+ {"outdated": [{"model": "<outdated_model>", "replacement": "<replacement_model>"}]}
43
+
44
+ INPUT:
45
+ #{agent!(:model_verifier).response}
46
+
47
+ Output ONLY a JSON object of exactly this shape, with no surrounding prose, carrying the
48
+ replacement through for each model you find a reference to:
49
+ {"references": [{"model": "<outdated_model>", "replacement": "<replacement_model>", "locations": ["<file_path>:<line_number>"]}]}
50
+
51
+ If you find no references, output {"references": []}.
52
+ PROMPT
53
+ end
54
+
55
+ agent(:updater) do |my|
56
+ finder = agent!(:model_finder)
57
+ skip! if finder.json![:references].blank?
58
+ my.session = finder.session
59
+ <<~PROMPT
60
+ For each reference you found of an outdated model, update the reference to use the suggested replacement model instead. Make sure to update all types of references, including documentation, doc comments and code references. Output the list of updated references in the following format:
61
+ <outdated_model_1> -> <replacement_model_1>:
62
+ - <file_path>:<line_number>
63
+ <outdated_model_2> -> <replacement_model_2>:
64
+ - <file_path>:<line_number>
65
+ ...
66
+ PROMPT
67
+ end
68
+
69
+ ruby(:output) do
70
+ if agent?(:updater)
71
+ puts "[OUTDATED MODELS & REPLACEMENTS]\n #{agent!(:model_verifier).response}"
72
+ puts "[REFERENCES IN CODEBASE]\n #{agent!(:model_finder).response}"
73
+ puts "[UPDATED REFERENCES]\n #{agent!(:updater).response}"
74
+ else
75
+ puts "No references to outdated models found — nothing to update."
76
+ end
77
+ end
78
+ end
@@ -230,7 +230,7 @@ module Roast
230
230
  #
231
231
  #: () -> bool
232
232
  def abort_on_failure?
233
- !!@values[:abort_on_failure]
233
+ @values.fetch(:abort_on_failure, true)
234
234
  end
235
235
 
236
236
  # Configure the cog to run external commands in the specified working directory
@@ -159,7 +159,7 @@ module Roast
159
159
  # Supports both relative shorthand paths like "greeting" and full absolute paths.
160
160
  #
161
161
  # @param path [String, Pathname] The template path to resolve. Can be:
162
- # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb
162
+ # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb or templates/greeting.md.erb
163
163
  # - With extension: "template.erb" -> searches for template.erb
164
164
  # - Absolute path: "/full/path/to/template.erb" -> uses as-is
165
165
  # @param args [Hash] Template variables for ERB interpolation
@@ -175,13 +175,14 @@ module Roast
175
175
  # 1. Absolute path as-is (if absolute)
176
176
  # 2-4. Workflow directory: path, path.erb, path.md.erb
177
177
  # 5-7. Workflow directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
178
- # 8-10. Current directory: path, path.erb, path.md.erb
179
- # 11-13. Current directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
178
+ # 8-10. Workflow directory templates/: templates/path, templates/path.erb, templates/path.md.erb
179
+ # 11-13. Current directory: path, path.erb, path.md.erb
180
+ # 14-16. Current directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
181
+ # 17-19. Current directory templates/: templates/path, templates/path.erb, templates/path.md.erb
182
+ # 20-22. Tilde-expanded path: path, path.erb, path.md.erb
180
183
  #
181
184
  #: (String | Pathname, ?Hash) -> String
182
185
  def template(path, args = {})
183
- # NOTE: Pathname does not expand ~ for home directory automatically.
184
- # This is tracked in issue https://github.com/Shopify/roast/issues/663.
185
186
  path = Pathname.new(path) unless path.is_a?(Pathname)
186
187
 
187
188
  # Priority stack of places to look for a matching file
@@ -201,17 +202,37 @@ module Roast
201
202
  candidate_paths << workflow_dir / "prompts" / "#{path}.erb"
202
203
  candidate_paths << workflow_dir / "prompts" / "#{path}.md.erb"
203
204
 
204
- # 8-10. Relative to current working directory
205
+ # 8-10. Relative to workflow directory templates folder
206
+ candidate_paths << workflow_dir / "templates" / path
207
+ candidate_paths << workflow_dir / "templates" / "#{path}.erb"
208
+ candidate_paths << workflow_dir / "templates" / "#{path}.md.erb"
209
+
210
+ # 11-13. Relative to current working directory
205
211
  pwd = Pathname.pwd
206
212
  candidate_paths << pwd / path
207
213
  candidate_paths << pwd / "#{path}.erb"
208
214
  candidate_paths << pwd / "#{path}.md.erb"
209
215
 
210
- # 11-13. Relative to current working directory prompts folder
216
+ # 14-16. Relative to current working directory prompts folder
211
217
  candidate_paths << pwd / "prompts" / path
212
218
  candidate_paths << pwd / "prompts" / "#{path}.erb"
213
219
  candidate_paths << pwd / "prompts" / "#{path}.md.erb"
214
220
 
221
+ # 17-19. Relative to current working directory templates folder
222
+ candidate_paths << pwd / "templates" / path
223
+ candidate_paths << pwd / "templates" / "#{path}.erb"
224
+ candidate_paths << pwd / "templates" / "#{path}.md.erb"
225
+
226
+ # 20-22. Tilde expanded path
227
+ begin
228
+ expanded_path = Pathname.new(File.expand_path(path))
229
+ candidate_paths << expanded_path
230
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.erb"))
231
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.md.erb"))
232
+ rescue ArgumentError
233
+ # File.expand_path raises when expanding ~something/foo (assuming "something" is not a real user).
234
+ # Nothing to do here, falls back to other candidate paths without tilde expansion.
235
+ end
215
236
  # Use the first path that exists
216
237
  resolved_path = candidate_paths.find(&:exist?)
217
238
 
@@ -51,7 +51,7 @@ module Roast
51
51
  def valid_provider!
52
52
  provider = @values[:provider] || VALID_PROVIDERS.first
53
53
  unless VALID_PROVIDERS.include?(provider)
54
- raise ArgumentError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
54
+ raise InvalidConfigError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
55
55
  end
56
56
 
57
57
  provider
@@ -77,7 +77,7 @@ module Roast
77
77
  raise ClaudeAlreadyStartedError if started?
78
78
 
79
79
  @started = true
80
- puts "[USER PROMPT] #{@prompt}" if @show_prompt
80
+ Event << { block: { header: "USER PROMPT", content: @prompt } } if @show_prompt
81
81
  _stdout, stderr, status = CommandRunner.execute(
82
82
  command_line,
83
83
  working_directory: @working_directory,
@@ -87,7 +87,7 @@ module Roast
87
87
 
88
88
  if status.success?
89
89
  @completed = true
90
- puts "[AGENT RESPONSE] #{@result.response}" if @show_response
90
+ Event << { block: { header: "AGENT RESPONSE", content: @result.response } } if @show_response
91
91
  else
92
92
  @failed = true
93
93
  @result.success = false
@@ -31,7 +31,7 @@ module Roast
31
31
  @content = hash.delete(:result) || ""
32
32
  @success = hash.delete(:success) || subtype == "success"
33
33
  if hash.delete(:is_error) || subtype == "error"
34
- @content = @content || hash.dig(:error, :message) || "Unknown error"
34
+ @content = @content.presence || hash.dig(:error, :message) || "Unknown error"
35
35
  hash.delete(:error)
36
36
  end
37
37