ace-review 0.49.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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. metadata +351 -0
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require "ace/b36ts"
6
+
7
+ module Ace
8
+ module Review
9
+ module Molecules
10
+ # Save review reports to task directories with compact ID filenames
11
+ class TaskReportSaver
12
+ # Save a review report to a task's reviews/ directory
13
+ # @param task_dir [String] Path to the task directory
14
+ # @param review_file [String] Path to the review file to copy
15
+ # @param review_data [Hash] Review metadata (preset, model, etc.)
16
+ # @return [Hash] Result with :success, :path, or :error
17
+ def self.save(task_dir, review_file, review_data)
18
+ # Validate inputs
19
+ return {success: false, error: "Task directory not found: #{task_dir}"} unless Dir.exist?(task_dir)
20
+ return {success: false, error: "Review file not found: #{review_file}"} unless File.exist?(review_file)
21
+
22
+ # Create reviews/ subdirectory if it doesn't exist
23
+ reviews_dir = File.join(task_dir, "reviews")
24
+ begin
25
+ FileUtils.mkdir_p(reviews_dir)
26
+ rescue SystemCallError, IOError => e
27
+ return {success: false, error: "Cannot create reviews directory: #{e.message}"}
28
+ end
29
+
30
+ # Generate filename
31
+ filename = generate_filename(review_data)
32
+ output_path = File.join(reviews_dir, filename)
33
+
34
+ # Copy review to task directory
35
+ begin
36
+ FileUtils.cp(review_file, output_path)
37
+ {success: true, path: output_path}
38
+ rescue SystemCallError, IOError => e
39
+ {success: false, error: "Failed to save review: #{e.message}"}
40
+ end
41
+ end
42
+
43
+ # Generate filename with compact ID for review report
44
+ # @param review_data [Hash] Review metadata (preset, model, etc.)
45
+ # @return [String] Filename with format: {compact_id}-model-preset-review.md
46
+ def self.generate_filename(review_data)
47
+ compact_id = Ace::B36ts.encode(Time.now)
48
+
49
+ # Use full model slug for uniqueness (e.g., "google:gemini-2.5-flash" -> "google-gemini-2-5-flash")
50
+ model = review_data[:model] || "unknown"
51
+ model_slug = Ace::Review::Atoms::SlugGenerator.generate(model)
52
+
53
+ preset = review_data[:preset] || "default"
54
+
55
+ # Sanitize preset name for filename
56
+ preset_slug = Ace::Review::Atoms::SlugGenerator.generate(preset)
57
+
58
+ "#{compact_id}-#{model_slug}-#{preset_slug}-review.md"
59
+ end
60
+
61
+ # Extract provider name from model string
62
+ # @param model [String] Model identifier (e.g., "google:gemini-2.5-flash", "gpt-4")
63
+ # @return [String] Provider name or sanitized model name
64
+ def self.extract_provider(model)
65
+ # Check for provider prefix (e.g., "google:", "openai:")
66
+ if model.include?(":")
67
+ provider = model.split(":").first
68
+ provider.gsub(/[^a-zA-Z0-9\-_]/, "-").downcase
69
+ else
70
+ # Use first part of model name (e.g., "gpt-4" -> "gpt", "claude-3" -> "claude")
71
+ parts = model.split("-")
72
+ if parts.length > 1 && parts.first =~ /^[a-z]+$/i
73
+ parts.first.downcase
74
+ else
75
+ # Fallback: sanitize entire model name
76
+ model.gsub(/[^a-zA-Z0-9\-_]/, "-").downcase.split("-").first
77
+ end
78
+ end
79
+ end
80
+
81
+ # ============================================================================
82
+ # Feedback Methods
83
+ # ============================================================================
84
+
85
+ # Get the feedback directory path for a task
86
+ # @param task_path [String] Path to the task directory
87
+ # @return [String] The feedback directory path
88
+ def self.feedback_path(task_path)
89
+ File.join(task_path, "feedback")
90
+ end
91
+
92
+ # Get the feedback archive directory path for a task
93
+ # @param task_path [String] Path to the task directory
94
+ # @return [String] The feedback archive directory path
95
+ def self.feedback_archive_path(task_path)
96
+ File.join(task_path, "feedback", "_archived")
97
+ end
98
+
99
+ # Save a feedback file to a task's feedback/ directory
100
+ # @param task_path [String] Path to the task directory
101
+ # @param feedback_file [String] Path to the feedback file to copy
102
+ # @param feedback_data [Hash] Optional metadata (currently unused, for future extension)
103
+ # @return [Hash] Result with :success, :path, or :error
104
+ def self.save_feedback(task_path, feedback_file, feedback_data = {})
105
+ # Validate inputs
106
+ return {success: false, error: "Task directory not found: #{task_path}"} unless Dir.exist?(task_path)
107
+ return {success: false, error: "Feedback file not found: #{feedback_file}"} unless File.exist?(feedback_file)
108
+
109
+ # Create feedback/ subdirectory if it doesn't exist
110
+ feedback_dir = feedback_path(task_path)
111
+ begin
112
+ FileUtils.mkdir_p(feedback_dir)
113
+ rescue SystemCallError, IOError => e
114
+ return {success: false, error: "Cannot create feedback directory: #{e.message}"}
115
+ end
116
+
117
+ # Use original filename for feedback files (they already have meaningful names)
118
+ filename = File.basename(feedback_file)
119
+ output_path = File.join(feedback_dir, filename)
120
+
121
+ # Copy feedback file to task directory
122
+ begin
123
+ FileUtils.cp(feedback_file, output_path)
124
+ {success: true, path: output_path}
125
+ rescue SystemCallError, IOError => e
126
+ {success: false, error: "Failed to save feedback: #{e.message}"}
127
+ end
128
+ end
129
+
130
+ # Archive a feedback file by moving it to the task's feedback/_archived/ directory
131
+ # @param task_path [String] Path to the task directory
132
+ # @param feedback_file [String] Path to the feedback file to archive
133
+ # @return [Hash] Result with :success, :path, or :error
134
+ def self.archive_feedback(task_path, feedback_file)
135
+ # Validate inputs
136
+ return {success: false, error: "Task directory not found: #{task_path}"} unless Dir.exist?(task_path)
137
+ return {success: false, error: "Feedback file not found: #{feedback_file}"} unless File.exist?(feedback_file)
138
+
139
+ # Create feedback/_archived/ subdirectory if it doesn't exist
140
+ archive_dir = feedback_archive_path(task_path)
141
+ begin
142
+ FileUtils.mkdir_p(archive_dir)
143
+ rescue SystemCallError, IOError => e
144
+ return {success: false, error: "Cannot create archive directory: #{e.message}"}
145
+ end
146
+
147
+ # Move feedback file to archive
148
+ filename = File.basename(feedback_file)
149
+ archive_path = File.join(archive_dir, filename)
150
+
151
+ begin
152
+ FileUtils.mv(feedback_file, archive_path)
153
+ {success: true, path: archive_path}
154
+ rescue SystemCallError, IOError => e
155
+ {success: false, error: "Failed to archive feedback: #{e.message}"}
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Molecules
6
+ # Resolve task references to task directory paths using ace-task
7
+ class TaskResolver
8
+ # Resolve a task reference to its directory path
9
+ # @param task_reference [String] Task reference (e.g., "114", "task.114", "8pp.t.q7w")
10
+ # @return [Hash, nil] Hash with :path, :task_id, or nil if not found
11
+ def self.resolve(task_reference)
12
+ # Try to load ace-task
13
+ begin
14
+ require "ace/task"
15
+ require "ace/task/organisms/task_manager"
16
+ rescue LoadError
17
+ return nil
18
+ end
19
+
20
+ # Use TaskManager to find the task
21
+ task_manager = Ace::Task::Organisms::TaskManager.new
22
+ task = task_manager.show(task_reference)
23
+
24
+ return nil unless task
25
+
26
+ # Extract task directory from task object
27
+ task_dir = task.path
28
+ return nil unless task_dir.to_s.strip != ""
29
+
30
+ {
31
+ path: task_dir,
32
+ spec_path: task.file_path,
33
+ task_id: task.id
34
+ }
35
+ rescue Ace::Task::Error => e
36
+ # Handle known ace-task errors
37
+ warn "Warning: Task '#{task_reference}' could not be resolved: #{e.message}"
38
+ nil
39
+ rescue => e
40
+ # Graceful degradation for unexpected errors
41
+ warn "Warning: Failed to resolve task '#{task_reference}': #{e.class} - #{e.message}"
42
+ warn e.backtrace.join("\n") if $DEBUG
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Review
7
+ module Organisms
8
+ # Central orchestrator for feedback item lifecycle management.
9
+ #
10
+ # Coordinates the extraction, storage, querying, and state transitions
11
+ # of feedback items from code reviews. Works with atoms and molecules
12
+ # to provide a unified interface for feedback management.
13
+ #
14
+ # With the feedback synthesis architecture, multiple review reports are
15
+ # synthesized into unique, deduplicated feedback items with reviewer arrays
16
+ # tracking which models found each issue.
17
+ #
18
+ # @example Extract and save feedback from review reports
19
+ # manager = FeedbackManager.new
20
+ # result = manager.extract_and_save(
21
+ # report_paths: ["review-report-gemini.md"],
22
+ # base_path: "/project"
23
+ # )
24
+ # result[:success] #=> true
25
+ # result[:items_count] #=> 5
26
+ #
27
+ # @example Multi-report synthesis (deduplicated with reviewer arrays)
28
+ # result = manager.extract_and_save(
29
+ # report_paths: [
30
+ # "review-report-gemini.md",
31
+ # "review-report-claude.md",
32
+ # "review-report-gpt.md"
33
+ # ],
34
+ # base_path: "/project"
35
+ # )
36
+ # # Produces ~11 unique findings (not 33 duplicates)
37
+ #
38
+ # @example Query feedback items
39
+ # items = manager.list("/project", status: "pending")
40
+ # item = manager.find("/project", "abc123")
41
+ # stats = manager.stats("/project")
42
+ #
43
+ # @example State transitions
44
+ # manager.verify("/project", "abc123", valid: true)
45
+ # manager.skip("/project", "abc123", reason: "Not applicable")
46
+ # manager.resolve("/project", "abc123", resolution: "Fixed in commit abc")
47
+ #
48
+ class FeedbackManager
49
+ attr_reader :synthesizer, :file_writer, :file_reader, :directory_manager
50
+
51
+ def initialize(
52
+ synthesizer: nil,
53
+ file_writer: nil,
54
+ file_reader: nil,
55
+ directory_manager: nil
56
+ )
57
+ @synthesizer = synthesizer || Molecules::FeedbackSynthesizer.new
58
+ @file_writer = file_writer || Molecules::FeedbackFileWriter.new
59
+ @file_reader = file_reader || Molecules::FeedbackFileReader.new
60
+ @directory_manager = directory_manager || Molecules::FeedbackDirectoryManager.new
61
+ end
62
+
63
+ # ========================================================================
64
+ # Extraction
65
+ # ========================================================================
66
+
67
+ # Extract and synthesize feedback items from review reports and save to disk
68
+ #
69
+ # For multiple reports, uses FeedbackSynthesizer to produce deduplicated
70
+ # findings with reviewer arrays. For single reports, extracts directly.
71
+ #
72
+ # @param report_paths [Array<String>] Paths to review report files
73
+ # @param base_path [String] Base project path for feedback directory
74
+ # @param model [String, nil] Model for synthesis/extraction (optional)
75
+ # @param session_dir [String, nil] Session directory for LLM output (optional)
76
+ # @return [Hash] Result with :success, :items_count, :paths, :metadata or :error
77
+ #
78
+ # @example Single report
79
+ # result = manager.extract_and_save(
80
+ # report_paths: ["session/review-report-gemini.md"],
81
+ # base_path: "/project"
82
+ # )
83
+ # result #=> { success: true, items_count: 3, paths: [...] }
84
+ #
85
+ # @example Multi-report synthesis
86
+ # result = manager.extract_and_save(
87
+ # report_paths: [
88
+ # "session/review-report-gemini.md",
89
+ # "session/review-report-claude.md"
90
+ # ],
91
+ # base_path: "/project"
92
+ # )
93
+ # # Produces deduplicated findings with reviewers arrays
94
+ def extract_and_save(report_paths:, base_path:, model: nil, session_dir: nil)
95
+ # Step 1: Synthesize feedback items from reports (handles deduplication)
96
+ synthesis_result = @synthesizer.synthesize(
97
+ report_paths: report_paths,
98
+ session_dir: session_dir,
99
+ model: model
100
+ )
101
+
102
+ unless synthesis_result[:success]
103
+ return {success: false, error: synthesis_result[:error]}
104
+ end
105
+
106
+ items = synthesis_result[:items]
107
+ return {success: true, items_count: 0, paths: [], metadata: synthesis_result[:metadata]} if items.empty?
108
+
109
+ # Step 2: Ensure feedback directory exists
110
+ feedback_dir = @directory_manager.ensure_directory(base_path)
111
+
112
+ # Step 3: Save each item
113
+ saved_paths = []
114
+ errors = []
115
+
116
+ items.each do |item|
117
+ write_result = @file_writer.write(item, feedback_dir)
118
+
119
+ if write_result[:success]
120
+ saved_paths << write_result[:path]
121
+ else
122
+ errors << "Failed to save #{item.id}: #{write_result[:error]}"
123
+ end
124
+ end
125
+
126
+ if errors.any? && saved_paths.empty?
127
+ return {success: false, error: errors.join("; ")}
128
+ end
129
+
130
+ {
131
+ success: true,
132
+ items_count: saved_paths.length,
133
+ paths: saved_paths,
134
+ metadata: synthesis_result[:metadata],
135
+ warnings: errors.any? ? errors : nil
136
+ }.compact
137
+ end
138
+
139
+ # ========================================================================
140
+ # Querying
141
+ # ========================================================================
142
+
143
+ # List feedback items with optional filters
144
+ #
145
+ # @param base_path [String] Base project path
146
+ # @param status [String, nil] Filter by status (draft, pending, invalid, skip, done)
147
+ # @param priority [String, nil] Filter by priority (critical, high, medium, low)
148
+ # @return [Array<Models::FeedbackItem>] Matching feedback items
149
+ #
150
+ # @example List all items
151
+ # items = manager.list("/project")
152
+ #
153
+ # @example Filter by status
154
+ # pending_items = manager.list("/project", status: "pending")
155
+ #
156
+ # @example Filter by status and priority
157
+ # high_pending = manager.list("/project", status: "pending", priority: "high")
158
+ def list(base_path, status: nil, priority: nil)
159
+ feedback_dir = @directory_manager.feedback_path(base_path)
160
+ return [] unless Dir.exist?(feedback_dir)
161
+
162
+ items = @file_reader.read_all(feedback_dir)
163
+
164
+ # Apply status filter
165
+ items = items.select { |item| item.status == status } if status
166
+
167
+ # Apply priority filter (supports exact match "high" or range "high+")
168
+ items = items.select { |item| Atoms::PriorityFilter.matches?(item.priority, priority) } if priority
169
+
170
+ # Sort by ID (chronological since IDs are timestamp-based)
171
+ items.sort_by(&:id)
172
+ end
173
+
174
+ # Find a specific feedback item by ID
175
+ #
176
+ # @param base_path [String] Base project path
177
+ # @param id [String] Feedback item ID (10-char Base36)
178
+ # @return [Models::FeedbackItem, nil] The found item or nil
179
+ #
180
+ # @example
181
+ # item = manager.find("/project", "abc123")
182
+ # item&.title #=> "Missing error handling"
183
+ def find(base_path, id)
184
+ feedback_dir = @directory_manager.feedback_path(base_path)
185
+ return nil unless Dir.exist?(feedback_dir)
186
+
187
+ # Find file matching ID pattern
188
+ files = Dir.glob(File.join(feedback_dir, "#{id}-*.s.md"))
189
+ return nil if files.empty?
190
+
191
+ # Read and return the item
192
+ result = @file_reader.read(files.first)
193
+ result[:success] ? result[:feedback_item] : nil
194
+ end
195
+
196
+ # Get statistics about feedback items
197
+ #
198
+ # @param base_path [String] Base project path
199
+ # @return [Hash] Statistics with status counts
200
+ #
201
+ # @example
202
+ # stats = manager.stats("/project")
203
+ # stats #=> { draft: 2, pending: 3, invalid: 1, skip: 0, done: 5, total: 11 }
204
+ def stats(base_path)
205
+ # Get items from active directory
206
+ active_items = list(base_path)
207
+
208
+ # Get archived items
209
+ archive_dir = @directory_manager.archive_path(base_path)
210
+ archived_items = []
211
+ if Dir.exist?(archive_dir)
212
+ archived_items = @file_reader.read_all(archive_dir)
213
+ end
214
+
215
+ all_items = active_items + archived_items
216
+
217
+ # Count by status
218
+ counts = Models::FeedbackItem::VALID_STATUSES.map do |status|
219
+ [status.to_sym, all_items.count { |item| item.status == status }]
220
+ end.to_h
221
+
222
+ counts[:total] = all_items.length
223
+ counts
224
+ end
225
+
226
+ # ========================================================================
227
+ # State Transitions
228
+ # ========================================================================
229
+
230
+ # Verify a feedback item (draft -> pending, draft -> invalid, or draft/pending -> skip)
231
+ #
232
+ # @param base_path [String] Base project path
233
+ # @param id [String] Feedback item ID
234
+ # @param valid [Boolean, nil] Whether the feedback is valid (mutually exclusive with skip:)
235
+ # @param skip [Boolean, nil] Whether to skip the feedback (mutually exclusive with valid:)
236
+ # @param research [String, nil] Verification research notes (optional)
237
+ # @return [Hash] Result with :success, :item or :error
238
+ #
239
+ # @example Mark as valid
240
+ # result = manager.verify("/project", "abc123", valid: true, research: "Confirmed issue")
241
+ #
242
+ # @example Mark as invalid
243
+ # result = manager.verify("/project", "abc123", valid: false, research: "False positive")
244
+ #
245
+ # @example Skip
246
+ # result = manager.verify("/project", "abc123", skip: true, research: "Design decision")
247
+ def verify(base_path, id, valid: nil, skip: nil, research: nil)
248
+ # Validate mutually exclusive options
249
+ if valid.nil? && skip.nil?
250
+ return {success: false, error: "Must specify either valid: or skip:"}
251
+ end
252
+ if !valid.nil? && !skip.nil?
253
+ return {success: false, error: "Cannot specify both valid: and skip:"}
254
+ end
255
+
256
+ target_status = if skip
257
+ "skip"
258
+ elsif valid
259
+ "pending"
260
+ else
261
+ "invalid"
262
+ end
263
+
264
+ allowed_from = if skip
265
+ %w[draft pending]
266
+ else
267
+ ["draft"]
268
+ end
269
+
270
+ transition(
271
+ base_path: base_path,
272
+ id: id,
273
+ to_status: target_status,
274
+ allowed_from: allowed_from,
275
+ updates: research ? {research: research} : {}
276
+ )
277
+ end
278
+
279
+ # Skip a feedback item (draft/pending -> skip)
280
+ #
281
+ # @param base_path [String] Base project path
282
+ # @param id [String] Feedback item ID
283
+ # @param reason [String, nil] Reason for skipping (optional, aliased to research)
284
+ # @return [Hash] Result with :success, :item or :error
285
+ #
286
+ # @example
287
+ # result = manager.skip("/project", "abc123", reason: "Out of scope for this PR")
288
+ #
289
+ # @note For new code, prefer verify(base_path, id, skip: true, research: reason)
290
+ def skip(base_path, id, reason: nil)
291
+ verify(base_path, id, skip: true, research: reason)
292
+ end
293
+
294
+ # Resolve a feedback item (pending -> done)
295
+ #
296
+ # @param base_path [String] Base project path
297
+ # @param id [String] Feedback item ID
298
+ # @param resolution [String] Description of how the issue was resolved
299
+ # @return [Hash] Result with :success, :item or :error
300
+ #
301
+ # @example
302
+ # result = manager.resolve("/project", "abc123", resolution: "Added try-catch in commit abc")
303
+ def resolve(base_path, id, resolution:)
304
+ transition(
305
+ base_path: base_path,
306
+ id: id,
307
+ to_status: "done",
308
+ allowed_from: ["pending"],
309
+ updates: {resolution: resolution}
310
+ )
311
+ end
312
+
313
+ private
314
+
315
+ # Perform a state transition on a feedback item
316
+ #
317
+ # @param base_path [String] Base project path
318
+ # @param id [String] Feedback item ID
319
+ # @param to_status [String] Target status
320
+ # @param allowed_from [Array<String>] Allowed source statuses
321
+ # @param updates [Hash] Additional attributes to update
322
+ # @return [Hash] Result with :success, :item or :error
323
+ def transition(base_path:, id:, to_status:, allowed_from:, updates: {})
324
+ # Find the item
325
+ item = find(base_path, id)
326
+ return {success: false, error: "Feedback item not found: #{id}"} unless item
327
+
328
+ # Validate transition
329
+ unless Atoms::FeedbackStateValidator.valid_transition?(item.status, to_status)
330
+ return {
331
+ success: false,
332
+ error: "Invalid transition from '#{item.status}' to '#{to_status}'. " \
333
+ "Allowed: #{Atoms::FeedbackStateValidator.allowed_transitions(item.status).join(", ")}"
334
+ }
335
+ end
336
+
337
+ # Check if transition is from an allowed source status
338
+ unless allowed_from.include?(item.status)
339
+ return {
340
+ success: false,
341
+ error: "Cannot #{to_status} from '#{item.status}'. Must be: #{allowed_from.join(" or ")}"
342
+ }
343
+ end
344
+
345
+ # Create updated item
346
+ updated_item = item.dup_with(status: to_status, **updates)
347
+
348
+ # Find the file path
349
+ feedback_dir = @directory_manager.feedback_path(base_path)
350
+ files = Dir.glob(File.join(feedback_dir, "#{id}-*.s.md"))
351
+ return {success: false, error: "Feedback file not found: #{id}"} if files.empty?
352
+
353
+ file_path = files.first
354
+
355
+ # Write updated item
356
+ write_result = @file_writer.write(updated_item, feedback_dir)
357
+ unless write_result[:success]
358
+ return {success: false, error: write_result[:error]}
359
+ end
360
+
361
+ # Archive if terminal state
362
+ if Atoms::FeedbackStateValidator.should_archive?(to_status)
363
+ archive_result = @directory_manager.archive(write_result[:path])
364
+ unless archive_result[:success]
365
+ # Log warning but don't fail the transition
366
+ warn "Warning: Failed to archive #{id}: #{archive_result[:error]}" if Ace::Review.debug?
367
+ end
368
+ end
369
+
370
+ # Remove old file if filename changed (due to slug or other reasons)
371
+ # This must happen after archiving to preserve the file for archival
372
+ if File.exist?(file_path) && file_path != write_result[:path]
373
+ begin
374
+ FileUtils.rm(file_path)
375
+ rescue => e
376
+ # Log warning but don't fail - file will remain in active directory
377
+ warn "Warning: Failed to remove old file #{file_path}: #{e.message}" if Ace::Review.debug?
378
+ end
379
+ end
380
+
381
+ {success: true, item: updated_item}
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end