ace-review 0.51.7 → 0.53.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6e9694e9a121f9b6b18479d7be2be32511483940cd667f842493b35b8c9d359
4
- data.tar.gz: e973c776a7b792365c3974fca973c31337529734f5eb0eda424da53c796e9cd6
3
+ metadata.gz: 647741c9f1b7814b5471a9a07c4f1693ac649f953d3f530bae67516a81cd7f0d
4
+ data.tar.gz: 520c59a0dda292b28d383215606fb7463d64d35f3b3ef0c024428243f2dc0870
5
5
  SHA512:
6
- metadata.gz: 9cb47a01bb00b68312c42c57b8fe694434818bcb813e1630711b5302fbf8b8caf0181239ce9b1932fdc9ade415de055fbee71f3a94b8ad01f61d9366d605de70
7
- data.tar.gz: f399541bbdd6d3ff98fddcb8d9c1305fb9ac73aff6a6f87058cbbeb0766a32ffc6498808a743e8052860e55724400fcf268ffaae6a4740baa44a0b3632f73190
6
+ metadata.gz: cb3239ee728103949252327de0a7b97de54ea1747ca3a8c2c2fd1e930281091bcb57ce14de3ca44041bf2d95d14608883b753f4af2b85f2d4d019fd4829bfb32
7
+ data.tar.gz: 892722919f96459f188ddd278f8d42f0538f81297fd9c87729302ca2072f9c62901f58396a7a17a72bb6c677394eb1f341927c2b3a0d53b199450a8dd845d0a1
data/CHANGELOG.md CHANGED
@@ -7,8 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.53.6] - 2026-04-24
11
+
12
+ ### Fixed
13
+ - Sized review prompts and adaptive review execution against the resolved concrete model target so role and alias-based reviews no longer rely on provider-wide context assumptions.
14
+
15
+ ## [0.53.5] - 2026-04-19
16
+
17
+ ### Technical
18
+ - Stabilized retained multi-model and reviewers-format E2E fixtures by pinning them to installed direct review models instead of ambient mixed-provider availability.
19
+
20
+ ## [0.53.4] - 2026-04-16
21
+
22
+ ### Technical
23
+ - Hardened retained multi-model and docs-path review E2E runners to persist `.stdout`, `.stderr`, and `.exit` artifacts consistently across success, timeout, and provider-constrained paths.
24
+
25
+ ## [0.53.3] - 2026-04-16
26
+
27
+ ### Fixed
28
+ - Updated retained `TS-REVIEW-001` execution fixtures to use installed direct review models, require explicit diff subjects, and drive the single/multi-model flows through the public executable review path instead of stopping at session preparation.
29
+
30
+ ## [0.53.2] - 2026-04-16
31
+
32
+ ### Fixed
33
+ - Switched retained `TS-REVIEW-001` multi-model fixtures from the missing `review-claude` role to installed review roles so the scenario validates real multi-provider review execution instead of local CLI availability drift.
34
+
35
+ ## [0.53.1] - 2026-04-16
36
+
37
+ ### Fixed
38
+ - Updated docs-path onboarding E2E verification to accept bounded timeout evidence when help discovery and review session artifacts prove the documented path is discoverable.
39
+
40
+ ## [0.53.0] - 2026-04-15
41
+
42
+ ### Changed
43
+ - Rewrote retained `TS-REVIEW-001` E2E goals to enforce public docs/help command-path guidance and added
44
+ `TC-003-docs-path-onboarding` coverage.
45
+ - Tightened E2E verifier contracts so provider/model unavailability is treated as a failure path instead of a
46
+ PASS-equivalent outcome.
47
+
48
+ ### Technical
49
+ - Updated scenario manifests and decision records to reflect three-goal execution (`X/3`) and the strict outcome
50
+ policy for onboarding-facing review workflows.
51
+
52
+ ## [0.52.1] - 2026-04-13
53
+
54
+ ### Changed
55
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
56
+
57
+ ### Technical
58
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
59
+
60
+
61
+ ## [0.52.0] - 2026-04-12
62
+
63
+ ### Changed
64
+ - Migrated package tests to the restarted `fast` / `feat` / `e2e` contract:
65
+ - moved deterministic package tests from legacy top-level folders into `test/fast/`
66
+ - moved former `test/integration/` deterministic coverage into `test/feat/`
67
+ - rewrote `TS-REVIEW-001` to retain only high-value execution workflows and added an E2E decision record
68
+ - Updated package docs to teach `ace-test ace-review`, `ace-test ace-review feat`, `ace-test ace-review all`, and `ace-test-e2e ace-review`.
69
+ - Expanded `as-review-pr` canonical skill metadata so public `review-pr` assign-step discovery is skill-owned rather than catalog-owned.
70
+ - Switched review GitHub CLI operations to use `Ace::Git::Molecules::GhCliExecutor` and removed the package-local `GhCliExecutor` implementation.
71
+
10
72
  ### Fixed
11
73
  - Added `diff:RANGE -- path` subject parsing so reviews can scope git diffs to specific files or directories with git-style path filters.
74
+ - Updated review GitHub workflows to correctly re-raise `Ace::Git` authentication/install errors after migrating to shared `GhCliExecutor`.
75
+
76
+ ## [0.51.10] - 2026-04-07
77
+
78
+ ### Changed
79
+ - Switched review GitHub CLI integrations to shared `Ace::Git::Molecules::GhCliExecutor` to remove duplicate implementations and align error handling with other packages.
80
+
81
+ ### Fixed
82
+ - Preserved review-path authentication and install error behavior after the executor migration.
12
83
 
13
84
  ## [0.51.6] - 2026-03-31
14
85
 
data/README.md CHANGED
@@ -38,5 +38,21 @@
38
38
 
39
39
  **Audit review history through session artifacts** - keep saved review sessions under `.ace-local/` for traceability, comparison, and handoff across contributors.
40
40
 
41
+ ## Testing
42
+
43
+ Run package deterministic checks with:
44
+
45
+ ```bash
46
+ ace-test ace-review
47
+ ace-test ace-review feat
48
+ ace-test ace-review all
49
+ ```
50
+
51
+ Run retained workflow scenarios with:
52
+
53
+ ```bash
54
+ ace-test-e2e ace-review
55
+ ```
56
+
41
57
  ---
42
58
  [Getting Started](docs/getting-started.md) | [Usage Guide](docs/usage.md) | [Handbook - Skills, Agents, Templates](docs/handbook.md) | Part of [ACE](https://github.com/cs3b/ace)
@@ -22,10 +22,19 @@ integration:
22
22
  - pi
23
23
  providers: {}
24
24
  assign:
25
- source: wfi://review/pr
26
25
  steps:
27
26
  - name: review-pr
28
27
  description: Review code changes for correctness, style, and best practices
28
+ prerequisites:
29
+ - name: create-pr
30
+ strength: required
31
+ reason: "Must have a PR to review"
32
+ produces: [review-feedback]
33
+ consumes: [pull-request]
34
+ when_to_skip:
35
+ - "No code changes since last review"
36
+ - "Changes are trivial (typo fix, config update)"
37
+ effort: medium
29
38
  tags: [review, quality]
30
39
  context:
31
40
  default: fork
@@ -42,7 +42,7 @@ ace-review --preset $1 --auto-execute
42
42
  ace-review --subject "$2" --auto-execute
43
43
 
44
44
  # File pattern review (NOTE: files: prefix is REQUIRED)
45
- ace-review --preset spec --subject "files:.ace-taskflow/**/*.md" --auto-execute
45
+ ace-review --preset spec --subject "files:.ace-task/**/*.md" --auto-execute
46
46
 
47
47
  # Multiple subjects merge automatically
48
48
  ace-review --subject pr:76 --subject files:CHANGELOG.md --auto-execute
@@ -1,161 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ace/llm"
4
+
3
5
  module Ace
4
6
  module Review
5
7
  module Atoms
6
- # Pure function for resolving model context limits
7
- #
8
- # Maps model names to their context window sizes. Handles provider prefixes
9
- # (google:, anthropic:, openai:) and uses pattern matching for model families.
10
- #
11
- # Resolution order:
12
- # 1. ace-llm provider config (context_limit in providers/*.yml)
13
- # 2. Hardcoded pattern matching (MODEL_LIMITS)
14
- # 3. Conservative default for unknown models
15
- #
16
- # @example Basic usage
17
- # ContextLimitResolver.resolve("google:gemini-2.5-pro")
18
- # #=> 1_000_000
19
- #
20
- # @example Without provider prefix
21
- # ContextLimitResolver.resolve("claude-3-sonnet")
22
- # #=> 200_000
8
+ # Review-local wrapper around ace-llm's resolved model limit lookup.
23
9
  module ContextLimitResolver
24
- # Conservative default for unknown models
25
- DEFAULT_LIMIT = 200_000
26
-
27
- # Model patterns and their context limits (fallback when ace-llm config unavailable)
28
- # Order matters - first match wins
29
- # Patterns use regex for flexible matching
30
- MODEL_LIMITS = [
31
- # Gemini models
32
- {pattern: /gemini-1\.5-pro/i, limit: 2_000_000},
33
- {pattern: /gemini-1\.5-flash/i, limit: 1_000_000},
34
- {pattern: /gemini-2\.5-pro/i, limit: 1_000_000},
35
- {pattern: /gemini-2\.5-flash/i, limit: 1_000_000},
36
- {pattern: /gemini-2\.0/i, limit: 1_000_000},
37
- # Fallback for any other gemini model
38
- {pattern: /gemini/i, limit: 1_000_000},
39
-
40
- # Claude models (all variants: opus, sonnet, haiku)
41
- {pattern: /claude.*opus/i, limit: 1_000_000},
42
- {pattern: /claude.*sonnet/i, limit: 1_000_000},
43
- {pattern: /claude.*haiku/i, limit: 1_000_000},
44
- # Fallback for any other claude model
45
- {pattern: /claude/i, limit: 1_000_000},
10
+ DEFAULT_LIMIT = Ace::LLM::Molecules::ModelLimitResolver::DEFAULT_CONTEXT_LIMIT
46
11
 
47
- # OpenAI models
48
- {pattern: /gpt-5\.\d/i, limit: 1_050_000},
49
- {pattern: /o4-/i, limit: 1_050_000},
50
- {pattern: /gpt-4o/i, limit: 128_000},
51
- {pattern: /gpt-4-turbo/i, limit: 128_000},
52
- {pattern: /gpt-4-32k/i, limit: 32_768},
53
- {pattern: /gpt-4-\d+-preview/i, limit: 128_000}, # gpt-4-1106-preview, gpt-4-0125-preview
54
- {pattern: /gpt-4-\d+$/i, limit: 8_192}, # legacy gpt-4-0613, etc.
55
- {pattern: /gpt-4$/i, limit: 8_192}, # base gpt-4 model
56
- {pattern: /o1-/i, limit: 200_000},
57
- {pattern: /o3-/i, limit: 200_000}
58
- ].freeze
59
-
60
- # Resolve context limit for a model
61
- #
62
- # Resolution order:
63
- # 1. ace-llm provider config (if available and provider specified)
64
- # 2. Hardcoded pattern matching
65
- # 3. Default limit
66
- #
67
- # @param model_name [String, nil] Model identifier, optionally with provider prefix
68
- # @return [Integer] Context limit in tokens
69
- #
70
- # @example With provider prefix
71
- # ContextLimitResolver.resolve("google:gemini-2.5-pro")
72
- # #=> 1_000_000
73
- #
74
- # @example Without provider prefix
75
- # ContextLimitResolver.resolve("claude-3-opus")
76
- # #=> 200_000
77
- #
78
- # @example Unknown model
79
- # ContextLimitResolver.resolve("unknown-model")
80
- # #=> 128_000
81
12
  def self.resolve(model_name)
82
- return DEFAULT_LIMIT if model_name.nil? || model_name.empty?
83
-
84
- # Try to get limit from ace-llm provider config first
85
- limit = load_from_ace_llm(model_name)
86
- return limit if limit
87
-
88
- # Fall back to hardcoded pattern matching
89
- normalized = strip_provider_prefix(model_name)
90
- match = MODEL_LIMITS.find { |entry| normalized.match?(entry[:pattern]) }
91
- match ? match[:limit] : DEFAULT_LIMIT
13
+ resolve_details(model_name).context_limit
92
14
  end
93
15
 
94
- # Get the default limit for unknown models
95
- #
96
- # @return [Integer] Default context limit
97
- def self.default_limit
98
- DEFAULT_LIMIT
99
- end
100
-
101
- # Load context limit from ace-llm provider configuration
102
- #
103
- # @param model_name [String] Model identifier with provider prefix
104
- # @return [Integer, nil] Context limit from config, or nil if not found
105
- def self.load_from_ace_llm(model_name)
106
- # Extract provider prefix (e.g., "google" from "google:gemini-2.5-pro")
107
- return nil unless model_name.include?(":")
108
-
109
- provider = model_name.split(":").first
110
- return nil if provider.nil? || provider.empty?
111
-
112
- # Try to load provider config via ace-llm
113
- config = load_provider_config(provider)
114
- return nil unless config
115
-
116
- # Get context_limit from provider config
117
- limit = config["context_limit"]
118
- limit.is_a?(Integer) ? limit : nil
16
+ def self.resolve_details(model_name)
17
+ Ace::LLM::Molecules::ModelLimitResolver.resolve(model_name)
119
18
  rescue
120
- nil # Fall back to hardcoded on any error
121
- end
122
- private_class_method :load_from_ace_llm
123
-
124
- # Load provider configuration from ace-llm
125
- #
126
- # @param provider [String] Provider name (e.g., "google", "anthropic")
127
- # @return [Hash, nil] Provider config hash or nil
128
- def self.load_provider_config(provider)
129
- # Try to use ace-llm's config loader if available
130
- return nil unless defined?(Ace::LLM::Molecules::ConfigLoader)
131
-
132
- resolver = Ace::Support::Config.create(
133
- config_dir: ".ace",
134
- defaults_dir: ".ace-defaults",
135
- gem_path: Ace::LLM::Molecules::ConfigLoader.gem_root
19
+ Ace::LLM::Molecules::ModelLimitResolver::ResolveResult.new(
20
+ provider: nil,
21
+ model: nil,
22
+ context_limit: DEFAULT_LIMIT,
23
+ output_limit: nil,
24
+ source: :fallback,
25
+ original_target: model_name.to_s
136
26
  )
137
-
138
- config = resolver.resolve_namespace("llm", filename: "providers/#{provider}")
139
- config.to_h
140
- rescue
141
- nil
142
27
  end
143
- private_class_method :load_provider_config
144
28
 
145
- # Strip provider prefix from model name
146
- #
147
- # @param model_name [String] Model identifier
148
- # @return [String] Model name without provider prefix
149
- #
150
- # @example
151
- # strip_provider_prefix("google:gemini-2.5-pro")
152
- # #=> "gemini-2.5-pro"
153
- def self.strip_provider_prefix(model_name)
154
- # Common provider prefixes
155
- model_name.sub(/\A(google|anthropic|openai|codex|cli):/, "")
29
+ def self.default_limit
30
+ DEFAULT_LIMIT
156
31
  end
157
-
158
- private_class_method :strip_provider_prefix
159
32
  end
160
33
  end
161
34
  end
@@ -26,6 +26,97 @@ module Ace
26
26
  # result[:items] #=> [FeedbackItem, ...] (with reviewers arrays)
27
27
  #
28
28
  class FeedbackSynthesizer
29
+ # Cached system prompt and prompt-path lookups to avoid repeated
30
+ # shell/file reads during test and command-heavy runs.
31
+ FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
32
+ Synthesize feedback from code review reports into unique findings.
33
+
34
+ For each unique issue found:
35
+ 1. Track which reviewers identified it (by their model names)
36
+ 2. Merge file references from all sources
37
+ 3. Use the most comprehensive description
38
+ 4. Mark consensus=true if 3+ reviewers agree
39
+
40
+ Return valid JSON with this schema:
41
+ {
42
+ "findings": [
43
+ {
44
+ "title": "Short title (max 60 chars)",
45
+ "files": ["path/file.rb:10-20"],
46
+ "reviewers": ["gemini-2.5-flash", "claude-3.5-sonnet"],
47
+ "consensus": false,
48
+ "priority": "high|medium|low|critical",
49
+ "finding": "Description of the issue",
50
+ "context": "Why this matters"
51
+ }
52
+ ]
53
+ }
54
+
55
+ IMPORTANT:
56
+ - When only one report: extract all findings as-is with that reviewer
57
+ - When multiple reports: deduplicate findings that describe the same issue
58
+ - When multiple reviewers find the same issue, list ALL of them in reviewers array
59
+ - Merge file arrays from all sources for each finding
60
+ - Return ONLY the JSON, no markdown code fences
61
+ PROMPT
62
+
63
+ class << self
64
+ def system_prompt(prompt_name)
65
+ system_prompt_cache_mutex.synchronize do
66
+ system_prompt_cache.fetch(prompt_name) do
67
+ prompt_path = resolve_prompt_path_cached(prompt_name)
68
+
69
+ if prompt_path && File.exist?(prompt_path)
70
+ system_prompt_cache[prompt_name] = File.read(prompt_path)
71
+ else
72
+ system_prompt_cache[prompt_name] = FALLBACK_SYSTEM_PROMPT
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def resolve_prompt_path_cached(prompt_name)
79
+ prompt_path_cache[prompt_name] ||= begin
80
+ nav_result = begin
81
+ `ace-nav prompt://#{prompt_name} 2>/dev/null`.strip
82
+ rescue
83
+ ""
84
+ end
85
+
86
+ return nav_result unless nav_result.empty?
87
+
88
+ File.join(__dir__, "../../../../handbook/prompts", prompt_name)
89
+ end
90
+ end
91
+
92
+ def clear_prompt_cache!
93
+ system_prompt_cache_mutex.synchronize do
94
+ @system_prompt_cache = {}
95
+ end
96
+ prompt_path_cache_mutex.synchronize do
97
+ @prompt_path_cache = {}
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def system_prompt_cache
104
+ @system_prompt_cache ||= {}
105
+ end
106
+
107
+ def prompt_path_cache
108
+ @prompt_path_cache ||= {}
109
+ end
110
+
111
+ def system_prompt_cache_mutex
112
+ @system_prompt_cache_mutex ||= Mutex.new
113
+ end
114
+
115
+ def prompt_path_cache_mutex
116
+ @prompt_path_cache_mutex ||= Mutex.new
117
+ end
118
+ end
119
+
29
120
  # Maximum combined report size before truncation (characters)
30
121
  MAX_COMBINED_SIZE = 200_000
31
122
 
@@ -138,51 +229,14 @@ module Ace
138
229
  #
139
230
  # @return [String] System prompt content
140
231
  def load_system_prompt
141
- prompt_name = "synthesize-feedback.system.md"
142
- prompt_path = resolve_prompt_path(prompt_name)
143
-
144
- if File.exist?(prompt_path)
145
- File.read(prompt_path)
146
- else
147
- fallback_synthesis_prompt
148
- end
232
+ self.class.system_prompt("synthesize-feedback.system.md")
149
233
  end
150
234
 
151
235
  # Fallback synthesis prompt (used when prompt file not found)
152
236
  #
153
237
  # @return [String] Basic synthesis prompt
154
238
  def fallback_synthesis_prompt
155
- <<~PROMPT
156
- Synthesize feedback from code review reports into unique findings.
157
-
158
- For each unique issue found:
159
- 1. Track which reviewers identified it (by their model names)
160
- 2. Merge file references from all sources
161
- 3. Use the most comprehensive description
162
- 4. Mark consensus=true if 3+ reviewers agree
163
-
164
- Return valid JSON with this schema:
165
- {
166
- "findings": [
167
- {
168
- "title": "Short title (max 60 chars)",
169
- "files": ["path/file.rb:10-20"],
170
- "reviewers": ["gemini-2.5-flash", "claude-3.5-sonnet"],
171
- "consensus": false,
172
- "priority": "high|medium|low|critical",
173
- "finding": "Description of the issue",
174
- "context": "Why this matters"
175
- }
176
- ]
177
- }
178
-
179
- IMPORTANT:
180
- - When only one report: extract all findings as-is with that reviewer
181
- - When multiple reports: deduplicate findings that describe the same issue
182
- - When multiple reviewers find the same issue, list ALL of them in reviewers array
183
- - Merge file arrays from all sources for each finding
184
- - Return ONLY the JSON, no markdown code fences
185
- PROMPT
239
+ FALLBACK_SYSTEM_PROMPT
186
240
  end
187
241
 
188
242
  # Build user prompt for report synthesis
@@ -522,16 +576,7 @@ module Ace
522
576
  # @param prompt_name [String] Prompt filename
523
577
  # @return [String] Resolved file path
524
578
  def resolve_prompt_path(prompt_name)
525
- # Try ace-nav first if available
526
- nav_result = begin
527
- `ace-nav prompt://#{prompt_name} 2>/dev/null`.strip
528
- rescue
529
- ""
530
- end
531
- return nav_result unless nav_result.empty?
532
-
533
- # Fallback to direct path
534
- File.join(__dir__, "../../../../handbook/prompts", prompt_name)
579
+ self.class.resolve_prompt_path_cached(prompt_name)
535
580
  end
536
581
 
537
582
  # Get default synthesis model from config
@@ -53,7 +53,8 @@ module Ace
53
53
  error: "Failed to post comment: #{result[:stderr]}"
54
54
  }
55
55
  end
56
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
56
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
57
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
57
58
  raise
58
59
  rescue => e
59
60
  {
@@ -68,7 +69,7 @@ module Ace
68
69
  # @return [Hash] Result with :success or :error
69
70
  def self.check_pr_state(gh_format)
70
71
  # Fetch PR metadata
71
- result = Ace::Review::Molecules::GhCliExecutor.execute(
72
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
72
73
  "pr",
73
74
  ["view", gh_format, "--json", "state,number"]
74
75
  )
@@ -169,7 +170,7 @@ module Ace
169
170
  file.flush
170
171
 
171
172
  # Post using gh pr comment
172
- Ace::Review::Molecules::GhCliExecutor.execute(
173
+ Ace::Git::Molecules::GhCliExecutor.execute(
173
174
  "pr",
174
175
  ["comment", gh_format, "--body-file", file.path]
175
176
  )
@@ -34,7 +34,7 @@ module Ace
34
34
  timeout = options[:timeout] || 30
35
35
 
36
36
  # Post comment using gh CLI
37
- result = Ace::Review::Molecules::GhCliExecutor.execute(
37
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
38
38
  "pr",
39
39
  ["comment", gh_format, "--body", body],
40
40
  timeout: timeout
@@ -54,7 +54,8 @@ module Ace
54
54
  error: "Failed to post reply: #{result[:stderr]}"
55
55
  }
56
56
  end
57
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
57
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
58
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
58
59
  raise
59
60
  rescue => e
60
61
  {
@@ -91,7 +92,7 @@ module Ace
91
92
  mutation = build_resolve_thread_mutation(thread_id)
92
93
 
93
94
  # Execute via gh api graphql
94
- result = Ace::Review::Molecules::GhCliExecutor.execute(
95
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
95
96
  "api",
96
97
  ["graphql", "-f", "query=#{mutation}"],
97
98
  timeout: timeout
@@ -118,7 +119,8 @@ module Ace
118
119
  error: "Failed to resolve thread: #{result[:stderr]}"
119
120
  }
120
121
  end
121
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
122
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
123
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
122
124
  raise
123
125
  rescue => e
124
126
  {
@@ -82,7 +82,7 @@ module Ace
82
82
  fields = "comments,reviews,number,title,author"
83
83
 
84
84
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
85
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
85
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
86
86
  end
87
87
 
88
88
  if result[:success]
@@ -116,7 +116,8 @@ module Ace
116
116
  success: false,
117
117
  error: "Failed to parse PR comments: #{e.message}"
118
118
  }
119
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
119
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
120
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
120
121
  raise
121
122
  rescue => e
122
123
  {
@@ -250,7 +251,7 @@ module Ace
250
251
 
251
252
  # Execute GraphQL query
252
253
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
253
- Ace::Review::Molecules::GhCliExecutor.execute(
254
+ Ace::Git::Molecules::GhCliExecutor.execute(
254
255
  "api",
255
256
  [
256
257
  "graphql",
@@ -382,7 +383,7 @@ module Ace
382
383
  # @return [String, nil] "owner/name" format or nil if not a GitHub repo
383
384
  def self.discover_repo_from_remote(options = {})
384
385
  timeout = options[:timeout] || 10
385
- result = Ace::Review::Molecules::GhCliExecutor.execute(
386
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
386
387
  "repo",
387
388
  ["view", "--json", "owner,name"],
388
389
  timeout: timeout
@@ -26,7 +26,7 @@ module Ace
26
26
 
27
27
  # Fetch diff with retry logic
28
28
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
29
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["diff", gh_format], timeout: timeout)
29
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["diff", gh_format], timeout: timeout)
30
30
  end
31
31
 
32
32
  if result[:success]
@@ -42,7 +42,8 @@ module Ace
42
42
  rescue Ace::Review::Errors::DiffTooLargeError
43
43
  # Fall back to local git diff when GitHub API rejects large diffs
44
44
  fetch_local_diff_fallback(pr_identifier, options)
45
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
45
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
46
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
46
47
  # Re-raise authentication and installation errors
47
48
  raise
48
49
  rescue => e
@@ -70,7 +71,7 @@ module Ace
70
71
  fields = "number,state,isDraft,title,body,author,headRefName,baseRefName,url"
71
72
 
72
73
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
73
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
74
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
74
75
  end
75
76
 
76
77
  if result[:success]
@@ -89,7 +90,8 @@ module Ace
89
90
  success: false,
90
91
  error: "Failed to parse PR metadata: #{e.message}"
91
92
  }
92
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
93
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
94
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
93
95
  raise
94
96
  rescue => e
95
97
  {
@@ -67,12 +67,19 @@ module Ace
67
67
  total_chars = (system_prompt&.length || 0) + (user_prompt&.length || 0)
68
68
  estimated_tokens = total_chars / 4 # Rough estimate: 4 chars per token
69
69
 
70
- context_limit = Ace::Review::Atoms::ContextLimitResolver.resolve(model)
70
+ limit_details = Ace::Review::Atoms::ContextLimitResolver.resolve_details(model)
71
+ context_limit = limit_details.context_limit
71
72
  threshold = (context_limit * PROMPT_SIZE_WARNING_RATIO).to_i
72
73
  return unless estimated_tokens > threshold
73
74
 
75
+ display_model = if limit_details.full_model && limit_details.full_model != model
76
+ "#{model} -> #{limit_details.full_model}"
77
+ else
78
+ model
79
+ end
80
+
74
81
  warn "Warning: Prompt size (~#{estimated_tokens.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')} tokens) " \
75
- "may exceed #{model} context limit (#{context_limit.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')} tokens)"
82
+ "may exceed #{display_model} context limit (#{context_limit.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')} tokens)"
76
83
  end
77
84
 
78
85
  # Check if Ruby API is available
@@ -14,8 +14,8 @@ module Ace
14
14
  # Default timeout for LLM queries (5 minutes)
15
15
  DEFAULT_LLM_TIMEOUT = 300
16
16
 
17
- # Warning threshold: 80% of typical 1M context window
18
- PROMPT_SIZE_WARNING_THRESHOLD = 800_000
17
+ # Warning threshold: 80% of the smallest resolved context window in the batch
18
+ PROMPT_SIZE_WARNING_RATIO = LlmExecutor::PROMPT_SIZE_WARNING_RATIO
19
19
 
20
20
  def initialize(max_concurrent: nil, llm_timeout: nil)
21
21
  # Read from config, fallback to default of 3, clamp to minimum 1
@@ -266,7 +266,14 @@ module Ace
266
266
  total_chars = (system_prompt&.length || 0) + (user_prompt&.length || 0)
267
267
  estimated_tokens = total_chars / 4 # Rough estimate: 4 chars per token
268
268
 
269
- return unless estimated_tokens > PROMPT_SIZE_WARNING_THRESHOLD
269
+ resolved_limits = models.map do |model|
270
+ Atoms::ContextLimitResolver.resolve_details(model)
271
+ end
272
+ min_context_limit = resolved_limits.map(&:context_limit).compact.min
273
+ return if min_context_limit.nil?
274
+
275
+ threshold = (min_context_limit * PROMPT_SIZE_WARNING_RATIO).to_i
276
+ return unless estimated_tokens > threshold
270
277
 
271
278
  model_list = models.join(", ")
272
279
  warn "Warning: Prompt size (~#{estimated_tokens.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')} tokens) " \
@@ -70,7 +70,7 @@ module Ace
70
70
  explicit_limit = context[:model_context_limit] || context["model_context_limit"]
71
71
 
72
72
  # Resolve model context limit
73
- model_limit = explicit_limit || Atoms::ContextLimitResolver.resolve(model)
73
+ model_limit = explicit_limit || Atoms::ContextLimitResolver.resolve_details(model).context_limit
74
74
 
75
75
  # Select and delegate to appropriate strategy
76
76
  selected = select_strategy(subject, model_limit, model)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Review
5
- VERSION = '0.51.7'
5
+ VERSION = '0.53.6'
6
6
  end
7
7
  end
data/lib/ace/review.rb CHANGED
@@ -37,7 +37,6 @@ require_relative "review/molecules/preset_manager"
37
37
  require_relative "review/molecules/prompt_composer"
38
38
  require_relative "review/molecules/nav_prompt_resolver"
39
39
  require_relative "review/molecules/subject_extractor"
40
- require_relative "review/molecules/gh_cli_executor"
41
40
  require_relative "review/molecules/gh_pr_fetcher"
42
41
  require_relative "review/molecules/gh_pr_comment_fetcher"
43
42
  require_relative "review/molecules/gh_comment_poster"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.51.7
4
+ version: 0.53.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -300,7 +300,6 @@ files:
300
300
  - lib/ace/review/molecules/feedback_file_reader.rb
301
301
  - lib/ace/review/molecules/feedback_file_writer.rb
302
302
  - lib/ace/review/molecules/feedback_synthesizer.rb
303
- - lib/ace/review/molecules/gh_cli_executor.rb
304
303
  - lib/ace/review/molecules/gh_comment_poster.rb
305
304
  - lib/ace/review/molecules/gh_comment_resolver.rb
306
305
  - lib/ace/review/molecules/gh_pr_comment_fetcher.rb
@@ -1,124 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "timeout"
5
-
6
- module Ace
7
- module Review
8
- module Molecules
9
- # Safely execute gh CLI commands with error handling
10
- class GhCliExecutor
11
- # Default timeout for gh CLI operations
12
- DEFAULT_GH_TIMEOUT = 30
13
-
14
- # Execute a gh CLI command
15
- #
16
- # @param subcommand [String] The gh subcommand (e.g., "pr", "api")
17
- # @param args [Array<String>] Arguments to pass to the subcommand
18
- # @param options [Hash] Additional options
19
- # @option options [Integer] :timeout Timeout in seconds (default: from config or 30)
20
- # @return [Hash] Result with :success, :stdout, :stderr, :exit_code
21
- def self.execute(subcommand, args = [], options = {})
22
- check_installed
23
-
24
- timeout_seconds = options[:timeout] ||
25
- Ace::Review.get("defaults", "gh_timeout") ||
26
- DEFAULT_GH_TIMEOUT
27
- command = ["gh", subcommand] + args
28
-
29
- run_command(command, timeout_seconds)
30
- rescue Timeout::Error
31
- raise Ace::Review::Errors::GhNetworkError, "gh command timed out after #{timeout_seconds} seconds"
32
- end
33
-
34
- # Check if gh CLI is installed
35
- #
36
- # @return [Boolean] true if installed
37
- # @raise [GhCliNotInstalledError] if not installed
38
- def self.check_installed
39
- result = execute_simple("--version")
40
- result[:success]
41
- rescue Ace::Review::Errors::GhCliNotInstalledError
42
- raise
43
- end
44
-
45
- # Check if user is authenticated with GitHub
46
- #
47
- # @return [Hash] Auth status with :authenticated, :username
48
- # @raise [GhAuthenticationError] if not authenticated
49
- def self.check_authenticated
50
- result = execute_simple("auth", ["status"])
51
-
52
- if result[:success]
53
- # Extract username from stderr (gh auth status outputs to stderr)
54
- username = extract_username(result[:stderr])
55
- {
56
- authenticated: true,
57
- username: username
58
- }
59
- else
60
- raise Ace::Review::Errors::GhAuthenticationError
61
- end
62
- end
63
-
64
- # Default timeout for simple operations
65
- DEFAULT_SIMPLE_TIMEOUT = 10
66
-
67
- # Execute a simple gh command without error checking
68
- # Used internally to avoid infinite recursion in check_installed
69
- #
70
- # @param command [String] The gh subcommand
71
- # @param args [Array<String>] Arguments
72
- # @param timeout_seconds [Integer] Timeout in seconds (default: from config or 10)
73
- # @return [Hash] Result hash
74
- def self.execute_simple(command, args = [], timeout_seconds = nil)
75
- timeout_seconds ||= Ace::Review.get("defaults", "gh_simple_timeout") || DEFAULT_SIMPLE_TIMEOUT
76
- cmd = ["gh", command] + args
77
- run_command(cmd, timeout_seconds)
78
- rescue Timeout::Error
79
- {
80
- success: false,
81
- stdout: "",
82
- stderr: "Command timed out",
83
- exit_code: 1
84
- }
85
- end
86
-
87
- # Extract username from gh auth status output
88
- #
89
- # @param output [String] Output from gh auth status
90
- # @return [String, nil] Username if found
91
- def self.extract_username(output)
92
- # gh auth status output format: "✓ Logged in to github.com as username ..."
93
- match = output.match(/Logged in to .+ as (\S+)/)
94
- match ? match[1] : nil
95
- end
96
-
97
- # Execute a command with timeout and error handling
98
- # Private helper to reduce duplication between execute and execute_simple
99
- #
100
- # @param command [Array<String>] Full command array including "gh"
101
- # @param timeout_seconds [Integer] Timeout in seconds
102
- # @return [Hash] Result with :success, :stdout, :stderr, :exit_code
103
- # @raise [GhCliNotInstalledError] if gh is not installed
104
- # @raise [Timeout::Error] if command times out (caller should handle)
105
- def self.run_command(command, timeout_seconds)
106
- stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
107
- Open3.capture3(*command)
108
- end
109
-
110
- {
111
- success: status.success?,
112
- stdout: stdout_str,
113
- stderr: stderr_str,
114
- exit_code: status.exitstatus
115
- }
116
- rescue Errno::ENOENT
117
- raise Ace::Review::Errors::GhCliNotInstalledError
118
- end
119
-
120
- private_class_method :execute_simple, :extract_username, :run_command
121
- end
122
- end
123
- end
124
- end