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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/stylesheets/coplan/application.css +956 -0
  3. data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
  5. data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
  6. data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
  7. data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
  8. data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
  9. data/app/controllers/coplan/application_controller.rb +78 -0
  10. data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
  11. data/app/controllers/coplan/comment_threads_controller.rb +148 -0
  12. data/app/controllers/coplan/comments_controller.rb +40 -0
  13. data/app/controllers/coplan/dashboard_controller.rb +6 -0
  14. data/app/controllers/coplan/plan_versions_controller.rb +29 -0
  15. data/app/controllers/coplan/plans_controller.rb +53 -0
  16. data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
  17. data/app/helpers/coplan/application_helper.rb +4 -0
  18. data/app/helpers/coplan/comments_helper.rb +20 -0
  19. data/app/helpers/coplan/markdown_helper.rb +36 -0
  20. data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
  21. data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
  22. data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
  23. data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
  24. data/app/jobs/coplan/application_job.rb +4 -0
  25. data/app/jobs/coplan/automated_review_job.rb +71 -0
  26. data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
  27. data/app/jobs/coplan/notification_job.rb +10 -0
  28. data/app/models/coplan/api_token.rb +50 -0
  29. data/app/models/coplan/application_record.rb +14 -0
  30. data/app/models/coplan/automated_plan_reviewer.rb +62 -0
  31. data/app/models/coplan/comment.rb +24 -0
  32. data/app/models/coplan/comment_thread.rb +151 -0
  33. data/app/models/coplan/current.rb +5 -0
  34. data/app/models/coplan/edit_lease.rb +57 -0
  35. data/app/models/coplan/edit_session.rb +61 -0
  36. data/app/models/coplan/plan.rb +28 -0
  37. data/app/models/coplan/plan_collaborator.rb +12 -0
  38. data/app/models/coplan/plan_version.rb +23 -0
  39. data/app/models/coplan/user.rb +20 -0
  40. data/app/policies/coplan/application_policy.rb +14 -0
  41. data/app/policies/coplan/comment_thread_policy.rb +23 -0
  42. data/app/policies/coplan/plan_policy.rb +15 -0
  43. data/app/services/coplan/ai_providers/anthropic.rb +21 -0
  44. data/app/services/coplan/ai_providers/open_ai.rb +44 -0
  45. data/app/services/coplan/broadcaster.rb +32 -0
  46. data/app/services/coplan/plans/apply_operations.rb +128 -0
  47. data/app/services/coplan/plans/commit_session.rb +186 -0
  48. data/app/services/coplan/plans/create.rb +30 -0
  49. data/app/services/coplan/plans/operation_error.rb +5 -0
  50. data/app/services/coplan/plans/position_resolver.rb +191 -0
  51. data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
  52. data/app/services/coplan/plans/review_response_parser.rb +65 -0
  53. data/app/services/coplan/plans/transform_range.rb +99 -0
  54. data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
  55. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
  56. data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
  57. data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
  58. data/app/views/coplan/comments/_comment.html.erb +9 -0
  59. data/app/views/coplan/dashboard/show.html.erb +8 -0
  60. data/app/views/coplan/plan_versions/index.html.erb +21 -0
  61. data/app/views/coplan/plan_versions/show.html.erb +29 -0
  62. data/app/views/coplan/plans/_header.html.erb +26 -0
  63. data/app/views/coplan/plans/edit.html.erb +15 -0
  64. data/app/views/coplan/plans/index.html.erb +35 -0
  65. data/app/views/coplan/plans/show.html.erb +56 -0
  66. data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
  67. data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
  68. data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
  69. data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
  70. data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
  71. data/app/views/coplan/settings/tokens/index.html.erb +32 -0
  72. data/app/views/layouts/coplan/application.html.erb +42 -0
  73. data/config/importmap.rb +1 -0
  74. data/config/routes.rb +41 -0
  75. data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
  76. data/lib/coplan/configuration.rb +17 -0
  77. data/lib/coplan/engine.rb +34 -0
  78. data/lib/coplan/version.rb +3 -0
  79. data/lib/coplan.rb +15 -0
  80. data/lib/tasks/coplan.rake +4 -0
  81. data/prompts/reviewers/routing.md +14 -0
  82. data/prompts/reviewers/scalability.md +14 -0
  83. data/prompts/reviewers/security.md +14 -0
  84. 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,5 @@
1
+ module CoPlan
2
+ module Plans
3
+ class OperationError < StandardError; end
4
+ end
5
+ 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