coplan-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/assets/stylesheets/coplan/application.css +956 -0
- data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
- data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
- data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
- data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
- data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
- data/app/controllers/coplan/application_controller.rb +78 -0
- data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
- data/app/controllers/coplan/comment_threads_controller.rb +148 -0
- data/app/controllers/coplan/comments_controller.rb +40 -0
- data/app/controllers/coplan/dashboard_controller.rb +6 -0
- data/app/controllers/coplan/plan_versions_controller.rb +29 -0
- data/app/controllers/coplan/plans_controller.rb +53 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
- data/app/helpers/coplan/application_helper.rb +4 -0
- data/app/helpers/coplan/comments_helper.rb +20 -0
- data/app/helpers/coplan/markdown_helper.rb +36 -0
- data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
- data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
- data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
- data/app/jobs/coplan/application_job.rb +4 -0
- data/app/jobs/coplan/automated_review_job.rb +71 -0
- data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
- data/app/jobs/coplan/notification_job.rb +10 -0
- data/app/models/coplan/api_token.rb +50 -0
- data/app/models/coplan/application_record.rb +14 -0
- data/app/models/coplan/automated_plan_reviewer.rb +62 -0
- data/app/models/coplan/comment.rb +24 -0
- data/app/models/coplan/comment_thread.rb +151 -0
- data/app/models/coplan/current.rb +5 -0
- data/app/models/coplan/edit_lease.rb +57 -0
- data/app/models/coplan/edit_session.rb +61 -0
- data/app/models/coplan/plan.rb +28 -0
- data/app/models/coplan/plan_collaborator.rb +12 -0
- data/app/models/coplan/plan_version.rb +23 -0
- data/app/models/coplan/user.rb +20 -0
- data/app/policies/coplan/application_policy.rb +14 -0
- data/app/policies/coplan/comment_thread_policy.rb +23 -0
- data/app/policies/coplan/plan_policy.rb +15 -0
- data/app/services/coplan/ai_providers/anthropic.rb +21 -0
- data/app/services/coplan/ai_providers/open_ai.rb +44 -0
- data/app/services/coplan/broadcaster.rb +32 -0
- data/app/services/coplan/plans/apply_operations.rb +128 -0
- data/app/services/coplan/plans/commit_session.rb +186 -0
- data/app/services/coplan/plans/create.rb +30 -0
- data/app/services/coplan/plans/operation_error.rb +5 -0
- data/app/services/coplan/plans/position_resolver.rb +191 -0
- data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
- data/app/services/coplan/plans/review_response_parser.rb +65 -0
- data/app/services/coplan/plans/transform_range.rb +99 -0
- data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
- data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
- data/app/views/coplan/comments/_comment.html.erb +9 -0
- data/app/views/coplan/dashboard/show.html.erb +8 -0
- data/app/views/coplan/plan_versions/index.html.erb +21 -0
- data/app/views/coplan/plan_versions/show.html.erb +29 -0
- data/app/views/coplan/plans/_header.html.erb +26 -0
- data/app/views/coplan/plans/edit.html.erb +15 -0
- data/app/views/coplan/plans/index.html.erb +35 -0
- data/app/views/coplan/plans/show.html.erb +56 -0
- data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
- data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
- data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
- data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
- data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
- data/app/views/coplan/settings/tokens/index.html.erb +32 -0
- data/app/views/layouts/coplan/application.html.erb +42 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
- data/lib/coplan/configuration.rb +17 -0
- data/lib/coplan/engine.rb +34 -0
- data/lib/coplan/version.rb +3 -0
- data/lib/coplan.rb +15 -0
- data/lib/tasks/coplan.rake +4 -0
- data/prompts/reviewers/routing.md +14 -0
- data/prompts/reviewers/scalability.md +14 -0
- data/prompts/reviewers/security.md +14 -0
- metadata +245 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class ApplyOperations
|
|
4
|
+
def self.call(content:, operations:)
|
|
5
|
+
new(content:, operations:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(content:, operations:)
|
|
9
|
+
@content = content.dup
|
|
10
|
+
@operations = operations
|
|
11
|
+
@applied = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
@operations.each_with_index do |op, index|
|
|
16
|
+
op = op.transform_keys(&:to_s)
|
|
17
|
+
applied_data = case op["op"]
|
|
18
|
+
when "replace_exact"
|
|
19
|
+
apply_replace_exact(op, index)
|
|
20
|
+
when "insert_under_heading"
|
|
21
|
+
apply_insert_under_heading(op, index)
|
|
22
|
+
when "delete_paragraph_containing"
|
|
23
|
+
apply_delete_paragraph_containing(op, index)
|
|
24
|
+
else
|
|
25
|
+
raise OperationError, "Operation #{index}: unknown op '#{op["op"]}'"
|
|
26
|
+
end
|
|
27
|
+
@applied << applied_data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
{ content: @content, applied: @applied }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def apply_replace_exact(op, index)
|
|
36
|
+
old_text = op["old_text"]
|
|
37
|
+
new_text = op["new_text"]
|
|
38
|
+
|
|
39
|
+
raise OperationError, "Operation #{index}: replace_exact requires 'old_text'" if old_text.blank?
|
|
40
|
+
raise OperationError, "Operation #{index}: replace_exact requires 'new_text'" if new_text.nil?
|
|
41
|
+
|
|
42
|
+
ranges = if op.key?("_pre_resolved_ranges")
|
|
43
|
+
op["_pre_resolved_ranges"]
|
|
44
|
+
else
|
|
45
|
+
Plans::PositionResolver.call(content: @content, operation: op).ranges
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
applied_data = op.except("_pre_resolved_ranges")
|
|
49
|
+
|
|
50
|
+
if ranges.length > 1
|
|
51
|
+
replacements = []
|
|
52
|
+
cumulative_delta = 0
|
|
53
|
+
|
|
54
|
+
ranges.sort_by { |r| r[0] }.each do |range|
|
|
55
|
+
adjusted_start = range[0] + cumulative_delta
|
|
56
|
+
adjusted_end = range[1] + cumulative_delta
|
|
57
|
+
|
|
58
|
+
@content = @content[0...adjusted_start] + new_text + @content[adjusted_end..]
|
|
59
|
+
|
|
60
|
+
delta = new_text.length - old_text.length
|
|
61
|
+
replacements << {
|
|
62
|
+
"resolved_range" => range,
|
|
63
|
+
"new_range" => [range[0], range[0] + new_text.length],
|
|
64
|
+
"delta" => delta
|
|
65
|
+
}
|
|
66
|
+
cumulative_delta += delta
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
applied_data["replacements"] = replacements
|
|
70
|
+
applied_data["total_delta"] = cumulative_delta
|
|
71
|
+
else
|
|
72
|
+
range = ranges[0]
|
|
73
|
+
@content = @content[0...range[0]] + new_text + @content[range[1]..]
|
|
74
|
+
|
|
75
|
+
delta = new_text.length - old_text.length
|
|
76
|
+
applied_data["resolved_range"] = range
|
|
77
|
+
applied_data["new_range"] = [range[0], range[0] + new_text.length]
|
|
78
|
+
applied_data["delta"] = delta
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
applied_data
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apply_insert_under_heading(op, index)
|
|
85
|
+
heading = op["heading"]
|
|
86
|
+
|
|
87
|
+
raise OperationError, "Operation #{index}: insert_under_heading requires 'heading'" if heading.blank?
|
|
88
|
+
raise OperationError, "Operation #{index}: insert_under_heading requires 'content'" if op["content"].nil?
|
|
89
|
+
|
|
90
|
+
insert_point = if op.key?("_pre_resolved_ranges")
|
|
91
|
+
op["_pre_resolved_ranges"][0]
|
|
92
|
+
else
|
|
93
|
+
Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
|
|
94
|
+
end
|
|
95
|
+
content_to_insert = "\n" + op["content"]
|
|
96
|
+
|
|
97
|
+
@content = @content[0...insert_point[0]] + content_to_insert + @content[insert_point[1]..]
|
|
98
|
+
|
|
99
|
+
applied_data = op.except("_pre_resolved_ranges")
|
|
100
|
+
applied_data["resolved_range"] = insert_point
|
|
101
|
+
applied_data["new_range"] = [insert_point[0], insert_point[0] + content_to_insert.length]
|
|
102
|
+
applied_data["delta"] = content_to_insert.length
|
|
103
|
+
applied_data
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def apply_delete_paragraph_containing(op, index)
|
|
107
|
+
needle = op["needle"]
|
|
108
|
+
|
|
109
|
+
raise OperationError, "Operation #{index}: delete_paragraph_containing requires 'needle'" if needle.blank?
|
|
110
|
+
|
|
111
|
+
range = if op.key?("_pre_resolved_ranges")
|
|
112
|
+
op["_pre_resolved_ranges"][0]
|
|
113
|
+
else
|
|
114
|
+
Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
deleted_length = range[1] - range[0]
|
|
118
|
+
@content = @content[0...range[0]] + @content[range[1]..]
|
|
119
|
+
|
|
120
|
+
applied_data = op.except("_pre_resolved_ranges")
|
|
121
|
+
applied_data["resolved_range"] = range
|
|
122
|
+
applied_data["new_range"] = [range[0], range[0]]
|
|
123
|
+
applied_data["delta"] = -deleted_length
|
|
124
|
+
applied_data
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
# Commits an EditSession's accumulated operations as a single PlanVersion.
|
|
4
|
+
#
|
|
5
|
+
# If the session's base_revision is behind current_revision, each
|
|
6
|
+
# operation's resolved ranges are transformed through intervening versions
|
|
7
|
+
# via TransformRange (OT). All versions must have positional metadata —
|
|
8
|
+
# TransformRange raises Conflict if any operation lacks it.
|
|
9
|
+
class CommitSession
|
|
10
|
+
class StaleSessionError < StandardError; end
|
|
11
|
+
class SessionConflictError < StandardError; end
|
|
12
|
+
class SessionNotOpenError < StandardError; end
|
|
13
|
+
|
|
14
|
+
def self.call(session:, change_summary: nil)
|
|
15
|
+
new(session:, change_summary:).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(session:, change_summary: nil)
|
|
19
|
+
@session = session
|
|
20
|
+
@change_summary = change_summary || session.change_summary
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
raise SessionNotOpenError, "Session is not open" unless @session.open?
|
|
25
|
+
|
|
26
|
+
plan = @session.plan
|
|
27
|
+
|
|
28
|
+
ActiveRecord::Base.transaction do
|
|
29
|
+
@session.lock!
|
|
30
|
+
raise SessionNotOpenError, "Session is not open" unless @session.open?
|
|
31
|
+
|
|
32
|
+
# No operations: just mark committed, no version created
|
|
33
|
+
unless @session.has_operations?
|
|
34
|
+
@session.update!(status: "committed", committed_at: Time.current)
|
|
35
|
+
return { session: @session, version: nil }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
plan.lock!
|
|
39
|
+
|
|
40
|
+
base_revision = @session.base_revision
|
|
41
|
+
current_revision = plan.current_revision
|
|
42
|
+
current_content = plan.current_content || ""
|
|
43
|
+
|
|
44
|
+
# Determine the content to use as the result
|
|
45
|
+
if base_revision == current_revision
|
|
46
|
+
# No intervening edits — use draft_content directly
|
|
47
|
+
new_content = @session.draft_content || current_content
|
|
48
|
+
final_ops = @session.operations_json
|
|
49
|
+
else
|
|
50
|
+
# Stale! Need to rebase through intervening versions
|
|
51
|
+
stale_gap = current_revision - base_revision
|
|
52
|
+
if stale_gap > 20
|
|
53
|
+
raise StaleSessionError, "Session is too stale (#{stale_gap} revisions behind, max 20)"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get intervening versions
|
|
57
|
+
intervening_versions = plan.plan_versions
|
|
58
|
+
.where("revision > ? AND revision <= ?", base_revision, current_revision)
|
|
59
|
+
.order(revision: :asc)
|
|
60
|
+
.to_a
|
|
61
|
+
|
|
62
|
+
# Transform each operation's resolved positions through intervening edits,
|
|
63
|
+
# then re-apply using the transformed positions (not re-resolving from scratch)
|
|
64
|
+
verification_content = current_content.dup
|
|
65
|
+
rebased_ops = []
|
|
66
|
+
@session.operations_json.each do |op_data|
|
|
67
|
+
op_data = op_data.transform_keys(&:to_s)
|
|
68
|
+
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
|
|
69
|
+
semantic_op = op_data.slice(*semantic_keys)
|
|
70
|
+
|
|
71
|
+
if op_data["resolved_range"]
|
|
72
|
+
begin
|
|
73
|
+
transformed_range = Plans::TransformRange.transform_through_versions(
|
|
74
|
+
op_data["resolved_range"], intervening_versions
|
|
75
|
+
)
|
|
76
|
+
rescue Plans::TransformRange::Conflict => e
|
|
77
|
+
raise SessionConflictError, "Conflict during rebase: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
verify_text_at_range!(verification_content, transformed_range, op_data)
|
|
81
|
+
semantic_op["_pre_resolved_ranges"] = [transformed_range]
|
|
82
|
+
elsif op_data.key?("replacements")
|
|
83
|
+
transformed_ranges = op_data["replacements"].map do |rep|
|
|
84
|
+
begin
|
|
85
|
+
Plans::TransformRange.transform_through_versions(
|
|
86
|
+
rep["resolved_range"], intervening_versions
|
|
87
|
+
)
|
|
88
|
+
rescue Plans::TransformRange::Conflict => e
|
|
89
|
+
raise SessionConflictError, "Conflict during rebase: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
transformed_ranges.each { |tr| verify_text_at_range!(verification_content, tr, op_data) }
|
|
94
|
+
semantic_op["_pre_resolved_ranges"] = transformed_ranges
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
rebased_ops << semantic_op
|
|
98
|
+
|
|
99
|
+
# Advance verification content so subsequent ops verify against
|
|
100
|
+
# the incrementally updated snapshot (not the original current_content).
|
|
101
|
+
step = Plans::ApplyOperations.call(content: verification_content, operations: [semantic_op])
|
|
102
|
+
verification_content = step[:content]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
result = Plans::ApplyOperations.call(content: current_content, operations: rebased_ops)
|
|
106
|
+
new_content = result[:content]
|
|
107
|
+
final_ops = result[:applied]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Create the version
|
|
111
|
+
new_revision = plan.current_revision + 1
|
|
112
|
+
diff = Diffy::Diff.new(current_content, new_content).to_s
|
|
113
|
+
|
|
114
|
+
version = PlanVersion.create!(
|
|
115
|
+
plan: plan,
|
|
116
|
+
revision: new_revision,
|
|
117
|
+
content_markdown: new_content,
|
|
118
|
+
actor_type: @session.actor_type,
|
|
119
|
+
actor_id: @session.actor_id,
|
|
120
|
+
change_summary: @change_summary,
|
|
121
|
+
diff_unified: diff.presence,
|
|
122
|
+
operations_json: final_ops,
|
|
123
|
+
base_revision: @session.base_revision
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
plan.update!(
|
|
127
|
+
current_plan_version: version,
|
|
128
|
+
current_revision: new_revision
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
plan.comment_threads.mark_out_of_date_for_new_version!(version)
|
|
132
|
+
|
|
133
|
+
@session.update!(
|
|
134
|
+
status: "committed",
|
|
135
|
+
committed_at: Time.current,
|
|
136
|
+
plan_version_id: version.id,
|
|
137
|
+
change_summary: @change_summary
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Broadcast update
|
|
141
|
+
Broadcaster.replace_to(
|
|
142
|
+
plan,
|
|
143
|
+
target: "plan-header",
|
|
144
|
+
partial: "coplan/plans/header",
|
|
145
|
+
locals: { plan: plan }
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
{ session: @session, version: version }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def verify_text_at_range!(content, range, op_data)
|
|
155
|
+
case op_data["op"]
|
|
156
|
+
when "replace_exact"
|
|
157
|
+
return unless op_data["old_text"]
|
|
158
|
+
actual_text = content[range[0]...range[1]]
|
|
159
|
+
return if actual_text == op_data["old_text"]
|
|
160
|
+
|
|
161
|
+
context_start = [range[0] - 200, 0].max
|
|
162
|
+
context_end = [range[1] + 200, content.length].min
|
|
163
|
+
raise SessionConflictError,
|
|
164
|
+
"Content changed at conflict region. Expected '#{op_data["old_text"]}' " \
|
|
165
|
+
"but found '#{actual_text}'. Context: ...#{content[context_start...context_end]}..."
|
|
166
|
+
when "insert_under_heading"
|
|
167
|
+
return unless op_data["heading"]
|
|
168
|
+
line_start = range[0] > 0 ? (content.rindex("\n", range[0] - 1) || -1) + 1 : 0
|
|
169
|
+
line_text = content[line_start...range[0]]
|
|
170
|
+
return if line_text&.match?(/\A#{Regexp.escape(op_data["heading"])}\s*\z/)
|
|
171
|
+
|
|
172
|
+
raise SessionConflictError,
|
|
173
|
+
"Heading changed at insert position. Expected '#{op_data["heading"]}' " \
|
|
174
|
+
"but found '#{line_text}'"
|
|
175
|
+
when "delete_paragraph_containing"
|
|
176
|
+
return unless op_data["needle"]
|
|
177
|
+
actual_text = content[range[0]...range[1]]
|
|
178
|
+
return if actual_text&.include?(op_data["needle"])
|
|
179
|
+
|
|
180
|
+
raise SessionConflictError,
|
|
181
|
+
"Paragraph at target position no longer contains '#{op_data["needle"]}'"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class Create
|
|
4
|
+
def self.call(title:, content:, user:)
|
|
5
|
+
new(title:, content:, user:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(title:, content:, user:)
|
|
9
|
+
@title = title
|
|
10
|
+
@content = content
|
|
11
|
+
@user = user
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
ActiveRecord::Base.transaction do
|
|
16
|
+
plan = Plan.create!(title: @title, created_by_user: @user)
|
|
17
|
+
version = PlanVersion.create!(
|
|
18
|
+
plan: plan,
|
|
19
|
+
revision: 1,
|
|
20
|
+
content_markdown: @content,
|
|
21
|
+
actor_type: "human",
|
|
22
|
+
actor_id: @user.id
|
|
23
|
+
)
|
|
24
|
+
plan.update!(current_plan_version: version, current_revision: 1)
|
|
25
|
+
plan
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class PositionResolver
|
|
4
|
+
Resolution = Data.define(:op, :ranges)
|
|
5
|
+
|
|
6
|
+
def self.call(content:, operation:)
|
|
7
|
+
new(content:, operation:).call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(content:, operation:)
|
|
11
|
+
@content = content
|
|
12
|
+
@op = operation.transform_keys(&:to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
case @op["op"]
|
|
17
|
+
when "replace_exact"
|
|
18
|
+
resolve_replace_exact
|
|
19
|
+
when "insert_under_heading"
|
|
20
|
+
resolve_insert_under_heading
|
|
21
|
+
when "delete_paragraph_containing"
|
|
22
|
+
resolve_delete_paragraph_containing
|
|
23
|
+
else
|
|
24
|
+
raise OperationError, "Unknown operation: #{@op["op"]}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def resolve_replace_exact
|
|
31
|
+
old_text = @op["old_text"]
|
|
32
|
+
raise OperationError, "replace_exact requires 'old_text'" if old_text.blank?
|
|
33
|
+
raise OperationError, "replace_exact requires 'new_text'" if @op["new_text"].nil?
|
|
34
|
+
|
|
35
|
+
replace_all = @op["replace_all"] == true
|
|
36
|
+
occurrence = (@op["occurrence"] || 1).to_i
|
|
37
|
+
raise OperationError, "replace_exact: occurrence must be >= 1, got #{occurrence}" if occurrence < 1
|
|
38
|
+
|
|
39
|
+
ranges = find_all_occurrences(old_text)
|
|
40
|
+
|
|
41
|
+
if ranges.empty?
|
|
42
|
+
raise OperationError, "replace_exact found 0 occurrences of the specified text"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if replace_all
|
|
46
|
+
Resolution.new(op: "replace_exact", ranges: ranges)
|
|
47
|
+
else
|
|
48
|
+
count = @op["count"]&.to_i
|
|
49
|
+
if count && count > 1
|
|
50
|
+
Resolution.new(op: "replace_exact", ranges: ranges)
|
|
51
|
+
else
|
|
52
|
+
if !@op.key?("occurrence") && !@op.key?("replace_all") && ranges.length > 1 && (!count || count == 1)
|
|
53
|
+
raise OperationError, "replace_exact found #{ranges.length} occurrences, expected at most 1"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if occurrence > ranges.length
|
|
57
|
+
raise OperationError, "replace_exact: occurrence #{occurrence} requested but only #{ranges.length} found"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Resolution.new(op: "replace_exact", ranges: [ranges[occurrence - 1]])
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_insert_under_heading
|
|
66
|
+
heading = @op["heading"]
|
|
67
|
+
raise OperationError, "insert_under_heading requires 'heading'" if heading.blank?
|
|
68
|
+
raise OperationError, "insert_under_heading requires 'content'" if @op["content"].nil?
|
|
69
|
+
|
|
70
|
+
pattern = /^#{Regexp.escape(heading)}[^\S\n]*$/
|
|
71
|
+
matches = []
|
|
72
|
+
@content.scan(pattern) do
|
|
73
|
+
match_end = Regexp.last_match.end(0)
|
|
74
|
+
matches << [match_end, match_end]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if matches.empty?
|
|
78
|
+
raise OperationError, "insert_under_heading found no heading matching '#{heading}'"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if matches.length > 1
|
|
82
|
+
raise OperationError, "insert_under_heading found #{matches.length} headings matching '#{heading}'"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Resolution.new(op: "insert_under_heading", ranges: matches)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_delete_paragraph_containing
|
|
89
|
+
needle = @op["needle"]
|
|
90
|
+
raise OperationError, "delete_paragraph_containing requires 'needle'" if needle.blank?
|
|
91
|
+
|
|
92
|
+
paragraphs = locate_paragraphs
|
|
93
|
+
matching = paragraphs.select { |para| para[:text].include?(needle) }
|
|
94
|
+
|
|
95
|
+
if matching.empty?
|
|
96
|
+
raise OperationError, "delete_paragraph_containing found no paragraph containing '#{needle}'"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if matching.length > 1
|
|
100
|
+
raise OperationError, "delete_paragraph_containing found #{matching.length} paragraphs containing '#{needle}'"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
para = matching.first
|
|
104
|
+
ranges = [deletion_range_for(para, paragraphs)]
|
|
105
|
+
|
|
106
|
+
Resolution.new(op: "delete_paragraph_containing", ranges: ranges)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def find_all_occurrences(text)
|
|
110
|
+
ranges = []
|
|
111
|
+
start_pos = 0
|
|
112
|
+
while (idx = @content.index(text, start_pos))
|
|
113
|
+
ranges << [idx, idx + text.length]
|
|
114
|
+
start_pos = idx + text.length
|
|
115
|
+
end
|
|
116
|
+
ranges
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Locate each paragraph's text and its position in the content.
|
|
120
|
+
# Paragraphs are separated by 2+ newlines. We track where each
|
|
121
|
+
# paragraph's text starts/ends and the separator that follows it.
|
|
122
|
+
def locate_paragraphs
|
|
123
|
+
return [] if @content.empty?
|
|
124
|
+
|
|
125
|
+
paragraphs = []
|
|
126
|
+
scanner_pos = 0
|
|
127
|
+
|
|
128
|
+
# Skip leading blank lines
|
|
129
|
+
if (m = @content.match(/\A(\n+)/, scanner_pos))
|
|
130
|
+
scanner_pos = m.end(0)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
while scanner_pos < @content.length
|
|
134
|
+
# Find the end of paragraph text (next \n\n or end of string)
|
|
135
|
+
next_sep = @content.index(/\n{2,}/, scanner_pos)
|
|
136
|
+
|
|
137
|
+
if next_sep
|
|
138
|
+
text_end = next_sep
|
|
139
|
+
# Find end of separator
|
|
140
|
+
sep_match = @content.match(/\n{2,}/, next_sep)
|
|
141
|
+
sep_end = sep_match.end(0)
|
|
142
|
+
else
|
|
143
|
+
text_end = @content.length
|
|
144
|
+
sep_end = @content.length
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
text = @content[scanner_pos...text_end]
|
|
148
|
+
paragraphs << { text: text, text_start: scanner_pos, text_end: text_end, sep_end: sep_end }
|
|
149
|
+
|
|
150
|
+
scanner_pos = sep_end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
paragraphs
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Determine the character range to delete so that removing
|
|
157
|
+
# content[range[0]...range[1]] produces clean output with
|
|
158
|
+
# correct paragraph spacing.
|
|
159
|
+
def deletion_range_for(para, all_paragraphs)
|
|
160
|
+
idx = all_paragraphs.index(para)
|
|
161
|
+
is_first = idx == 0
|
|
162
|
+
is_last = idx == all_paragraphs.length - 1
|
|
163
|
+
|
|
164
|
+
if all_paragraphs.length == 1
|
|
165
|
+
# Only paragraph — delete everything
|
|
166
|
+
[0, @content.length]
|
|
167
|
+
elsif is_first
|
|
168
|
+
# First paragraph: delete from text_start through the separator after it,
|
|
169
|
+
# so the next paragraph becomes the start.
|
|
170
|
+
[para[:text_start], para[:sep_end]]
|
|
171
|
+
elsif is_last
|
|
172
|
+
# Last paragraph: delete from the separator before it (end of previous
|
|
173
|
+
# paragraph's text) to the end of this paragraph's text.
|
|
174
|
+
prev = all_paragraphs[idx - 1]
|
|
175
|
+
[prev[:text_end], para[:text_end]]
|
|
176
|
+
else
|
|
177
|
+
# Middle paragraph: delete from end of previous paragraph's text
|
|
178
|
+
# through the separator after this paragraph, but keep one separator
|
|
179
|
+
# between the previous and next paragraphs.
|
|
180
|
+
# Simplest: delete from previous text_end to this paragraph's sep_end,
|
|
181
|
+
# then the next paragraph starts right there. But we need to preserve
|
|
182
|
+
# one separator. Instead: delete this paragraph's text_start through
|
|
183
|
+
# sep_end. That removes the paragraph and its trailing separator, and
|
|
184
|
+
# the separator before it (from previous text_end to this text_start)
|
|
185
|
+
# becomes the separator between prev and next.
|
|
186
|
+
[para[:text_start], para[:sep_end]]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class ReviewPromptFormatter
|
|
4
|
+
RESPONSE_FORMAT_INSTRUCTIONS = <<~INSTRUCTIONS.freeze
|
|
5
|
+
You MUST respond with a JSON array of feedback items. Each item is an object with two keys:
|
|
6
|
+
- "anchor_text": An exact substring copied verbatim from the plan document that this feedback applies to. Keep it short (a phrase or single sentence). Must match the plan text exactly. Use null for general feedback not tied to specific text.
|
|
7
|
+
- "comment": Your feedback in Markdown. Be concise and actionable.
|
|
8
|
+
|
|
9
|
+
Example response:
|
|
10
|
+
```json
|
|
11
|
+
[
|
|
12
|
+
{"anchor_text": "API tokens scoped to a user", "comment": "Consider adding token expiration by default. Long-lived tokens without expiry are a common security risk."},
|
|
13
|
+
{"anchor_text": null, "comment": "Overall the plan looks solid. One general concern: there's no mention of audit logging for administrative actions."}
|
|
14
|
+
]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Return ONLY the JSON array. No other text before or after it.
|
|
18
|
+
INSTRUCTIONS
|
|
19
|
+
|
|
20
|
+
def self.call(reviewer_prompt:)
|
|
21
|
+
"#{reviewer_prompt}\n\n#{RESPONSE_FORMAT_INSTRUCTIONS}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class ReviewResponseParser
|
|
4
|
+
def self.call(response_text, plan_content:)
|
|
5
|
+
new(response_text, plan_content:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(response_text, plan_content:)
|
|
9
|
+
@response_text = response_text
|
|
10
|
+
@plan_content = plan_content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
items = parse_json
|
|
15
|
+
items.filter_map { |item| normalize_item(item) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def parse_json
|
|
21
|
+
json_text = extract_json_from_response
|
|
22
|
+
parsed = JSON.parse(json_text)
|
|
23
|
+
|
|
24
|
+
unless parsed.is_a?(Array)
|
|
25
|
+
return fallback_single_comment
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parsed
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
fallback_single_comment
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract_json_from_response
|
|
34
|
+
# Strip markdown code fences if present
|
|
35
|
+
text = @response_text.strip
|
|
36
|
+
if text.start_with?("```")
|
|
37
|
+
text = text.sub(/\A```(?:json)?\s*\n?/, "").sub(/\n?```\s*\z/, "")
|
|
38
|
+
end
|
|
39
|
+
text
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalize_item(item)
|
|
43
|
+
return nil unless item.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
anchor = item["anchor_text"].presence
|
|
46
|
+
comment = item["comment"].to_s.strip
|
|
47
|
+
|
|
48
|
+
return nil if comment.blank?
|
|
49
|
+
|
|
50
|
+
# Verify anchor_text actually exists in the plan content
|
|
51
|
+
if anchor && !@plan_content.include?(anchor)
|
|
52
|
+
# Anchor doesn't match — demote to unanchored with the quote in the comment
|
|
53
|
+
comment = "> #{anchor}\n\n#{comment}"
|
|
54
|
+
anchor = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{ anchor_text: anchor, comment: comment }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fallback_single_comment
|
|
61
|
+
[{ "anchor_text" => nil, "comment" => @response_text }]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|