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.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- 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
|