@formthefog/stratus 2026.2.24 → 2026.3.19

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 (75) hide show
  1. package/.github/sentinel/action.yml +100 -0
  2. package/.github/sentinel/dist/codebase.d.ts +3 -0
  3. package/.github/sentinel/dist/codebase.d.ts.map +1 -0
  4. package/.github/sentinel/dist/context.d.ts +6 -0
  5. package/.github/sentinel/dist/context.d.ts.map +1 -0
  6. package/.github/sentinel/dist/fixer.d.ts +6 -0
  7. package/.github/sentinel/dist/fixer.d.ts.map +1 -0
  8. package/.github/sentinel/dist/index.d.ts +1 -0
  9. package/.github/sentinel/dist/index.d.ts.map +1 -0
  10. package/.github/sentinel/dist/index.js +68808 -0
  11. package/.github/sentinel/dist/index.js.map +1 -0
  12. package/.github/sentinel/dist/licenses.txt +1152 -0
  13. package/.github/sentinel/dist/models/anthropic.d.ts +26 -0
  14. package/.github/sentinel/dist/models/anthropic.d.ts.map +1 -0
  15. package/.github/sentinel/dist/models/openai.d.ts +26 -0
  16. package/.github/sentinel/dist/models/openai.d.ts.map +1 -0
  17. package/.github/sentinel/dist/models/openrouter.d.ts +31 -0
  18. package/.github/sentinel/dist/models/openrouter.d.ts.map +1 -0
  19. package/.github/sentinel/dist/models/types.d.ts +37 -0
  20. package/.github/sentinel/dist/models/types.d.ts.map +1 -0
  21. package/.github/sentinel/dist/orchestrator.d.ts +3 -0
  22. package/.github/sentinel/dist/orchestrator.d.ts.map +1 -0
  23. package/.github/sentinel/dist/policy.d.ts +15 -0
  24. package/.github/sentinel/dist/policy.d.ts.map +1 -0
  25. package/.github/sentinel/dist/reporter.d.ts +8 -0
  26. package/.github/sentinel/dist/reporter.d.ts.map +1 -0
  27. package/.github/sentinel/dist/responder.d.ts +6 -0
  28. package/.github/sentinel/dist/responder.d.ts.map +1 -0
  29. package/.github/sentinel/dist/router.d.ts +2 -0
  30. package/.github/sentinel/dist/router.d.ts.map +1 -0
  31. package/.github/sentinel/dist/schemas/config.d.ts +195 -0
  32. package/.github/sentinel/dist/schemas/config.d.ts.map +1 -0
  33. package/.github/sentinel/dist/schemas/fix.d.ts +130 -0
  34. package/.github/sentinel/dist/schemas/fix.d.ts.map +1 -0
  35. package/.github/sentinel/dist/schemas/review.d.ts +275 -0
  36. package/.github/sentinel/dist/schemas/review.d.ts.map +1 -0
  37. package/.github/sentinel/dist/sourcemap-register.js +1 -0
  38. package/.github/sentinel/dist/subway.d.ts +31 -0
  39. package/.github/sentinel/dist/subway.d.ts.map +1 -0
  40. package/.github/sentinel/dist/types.d.ts +210 -0
  41. package/.github/sentinel/dist/types.d.ts.map +1 -0
  42. package/.github/sentinel/package-lock.json +2389 -0
  43. package/.github/sentinel/package.json +29 -0
  44. package/.github/sentinel/src/codebase.ts +265 -0
  45. package/.github/sentinel/src/context.ts +182 -0
  46. package/.github/sentinel/src/fixer.ts +353 -0
  47. package/.github/sentinel/src/index.ts +263 -0
  48. package/.github/sentinel/src/models/anthropic.ts +244 -0
  49. package/.github/sentinel/src/models/openai.ts +242 -0
  50. package/.github/sentinel/src/models/openrouter.ts +319 -0
  51. package/.github/sentinel/src/models/types.ts +35 -0
  52. package/.github/sentinel/src/orchestrator.ts +287 -0
  53. package/.github/sentinel/src/policy.ts +133 -0
  54. package/.github/sentinel/src/reporter.ts +666 -0
  55. package/.github/sentinel/src/responder.ts +156 -0
  56. package/.github/sentinel/src/router.ts +308 -0
  57. package/.github/sentinel/src/schemas/config.ts +84 -0
  58. package/.github/sentinel/src/schemas/fix.ts +44 -0
  59. package/.github/sentinel/src/schemas/review.ts +73 -0
  60. package/.github/sentinel/src/subway.ts +250 -0
  61. package/.github/sentinel/src/types.ts +234 -0
  62. package/.github/sentinel/tsconfig.json +19 -0
  63. package/.github/sentinel.yml +34 -0
  64. package/.github/workflows/sentinel.yml +55 -0
  65. package/README.md +88 -102
  66. package/SECURITY.md +21 -10
  67. package/TROUBLESHOOTING.md +2 -2
  68. package/index.ts +219 -109
  69. package/openclaw.plugin.json +50 -26
  70. package/package.json +1 -1
  71. package/skills/stratus-info/SKILL.md +70 -10
  72. package/src/client.ts +78 -18
  73. package/src/config.ts +29 -8
  74. package/src/setup.ts +53 -61
  75. package/src/types.ts +11 -0
@@ -0,0 +1,319 @@
1
+ import OpenAI from "openai"
2
+ import * as core from "@actions/core"
3
+ import type { ModelClient, ReviewRequest, CritiqueRequest, CritiqueResponseRequest } from "./types"
4
+ import type { ModelReview, CritiqueOutput, CritiqueResponse, ReviewFinding, TokenUsage } from "../types"
5
+ import { parseModelReview, parseCritiqueOutput, parseCritiqueResponse } from "../schemas/review"
6
+
7
+ const ARCHITECT_REVIEW_SYSTEM = `You are a principal engineer conducting a code review. You are thorough, skeptical, and focused on long-term code health.
8
+
9
+ Your priorities:
10
+ 1. Intent mismatch — does the code do what the PR says it does?
11
+ 2. Hidden edge cases — what breaks under unusual input or concurrency?
12
+ 3. Correctness risks — logic errors, off-by-one, race conditions, null derefs
13
+ 4. Security — injection, XSS, credential exposure, unsafe deserialization
14
+ 5. Test gaps — what should be tested that isn't?
15
+ 6. Maintainability — will this be clear in 6 months?
16
+
17
+ You are NOT a style checker. Only flag real problems that affect correctness, security, or maintainability.
18
+
19
+ Respond with a JSON object matching this exact schema:
20
+ {
21
+ "summary": "2-3 sentence overall assessment",
22
+ "severity": "low | medium | high | critical",
23
+ "confidence": 0.0-1.0,
24
+ "findings": [
25
+ {
26
+ "type": "bug | security | performance | maintainability | test_gap | architecture",
27
+ "severity": "low | medium | high | critical",
28
+ "title": "short title",
29
+ "file": "path/to/file",
30
+ "line_start": 1,
31
+ "line_end": 1,
32
+ "explanation": "detailed explanation of the issue",
33
+ "suggested_fix": "optional concrete fix",
34
+ "confidence": 0.0-1.0
35
+ }
36
+ ],
37
+ "merge_blocking": true/false,
38
+ "needs_human_attention": true/false
39
+ }
40
+
41
+ If the code is clean, return an empty findings array with a positive summary.
42
+ Output ONLY the JSON object, no markdown fences.`
43
+
44
+ const ENGINEER_REVIEW_SYSTEM = `You are a repo-aware implementation engineer reviewing code changes. You are precise, concrete, and focused on correctness at the code level.
45
+
46
+ Your priorities:
47
+ 1. Concrete bugs — specific lines with specific problems
48
+ 2. Input validation — missing checks, unhandled errors, boundary conditions
49
+ 3. Type safety — incorrect types, unsafe casts, missing null checks
50
+ 4. Resource handling — unclosed connections, memory leaks, missing cleanup
51
+ 5. Test coverage — specific test cases that should exist for this change
52
+ 6. Convention violations — does this match the rest of the codebase?
53
+
54
+ Focus on code-level specifics, not high-level architecture. Be precise about file paths and line numbers.
55
+
56
+ Respond with a JSON object matching this exact schema:
57
+ {
58
+ "summary": "2-3 sentence code-level assessment",
59
+ "severity": "low | medium | high | critical",
60
+ "confidence": 0.0-1.0,
61
+ "findings": [
62
+ {
63
+ "type": "bug | security | performance | maintainability | test_gap | architecture",
64
+ "severity": "low | medium | high | critical",
65
+ "title": "short title",
66
+ "file": "path/to/file",
67
+ "line_start": 1,
68
+ "line_end": 1,
69
+ "explanation": "detailed explanation with code reference",
70
+ "suggested_fix": "concrete code fix",
71
+ "confidence": 0.0-1.0
72
+ }
73
+ ],
74
+ "merge_blocking": true/false,
75
+ "needs_human_attention": true/false
76
+ }
77
+
78
+ If the code is clean, return an empty findings array with a positive summary.
79
+ Output ONLY the JSON object, no markdown fences.`
80
+
81
+ const CRITIQUE_SYSTEM = `You are a principal engineer reviewing another engineer's code review findings.
82
+
83
+ For each finding from the other reviewer:
84
+ 1. If you agree, acknowledge it and explain why it matters
85
+ 2. If you disagree, explain specifically why with code references
86
+ 3. Identify any issues they missed entirely
87
+
88
+ You MUST acknowledge the strongest points from the other review, even if you disagree with others.
89
+
90
+ Respond with a JSON object:
91
+ {
92
+ "agreed_findings": ["description of each finding you agree with"],
93
+ "disputed_findings": [{"finding": "what they said", "reason": "why you disagree"}],
94
+ "missed_issues": [same schema as review findings above],
95
+ "overall_assessment": "your synthesis",
96
+ "revised_severity": "low | medium | high | critical"
97
+ }
98
+
99
+ Output ONLY the JSON object, no markdown fences.`
100
+
101
+ const CRITIQUE_RESPONSE_SYSTEM = `You are an implementation engineer responding to a senior reviewer's critique of your code review.
102
+
103
+ For each critique:
104
+ 1. If they're right, accept it explicitly
105
+ 2. If you disagree, provide a specific rebuttal with code references
106
+ 3. Update your findings list based on valid critiques
107
+
108
+ You MUST accept valid critiques. Do not be defensive.
109
+
110
+ Respond with a JSON object:
111
+ {
112
+ "accepted": ["description of each accepted critique"],
113
+ "disputed": [{"critique": "what they said", "rebuttal": "your specific rebuttal"}],
114
+ "revised_findings": [same schema as review findings — your FINAL revised list],
115
+ "final_summary": "updated assessment incorporating valid critiques"
116
+ }
117
+
118
+ Output ONLY the JSON object, no markdown fences.`
119
+
120
+ type ClientRole = "anthropic" | "openai"
121
+
122
+ export class OpenRouterClient implements ModelClient {
123
+ name: ClientRole
124
+ private client: OpenAI
125
+ private model: string
126
+ private role: ClientRole
127
+
128
+ constructor(apiKey: string, model: string, role: ClientRole) {
129
+ this.client = new OpenAI({
130
+ apiKey,
131
+ baseURL: "https://openrouter.ai/api/v1",
132
+ defaultHeaders: {
133
+ "HTTP-Referer": "https://github.com/hathbanger/pr-sentinel",
134
+ "X-Title": "Sentinel",
135
+ },
136
+ })
137
+ this.model = model
138
+ this.role = role
139
+ this.name = role
140
+ }
141
+
142
+ private get reviewSystem(): string {
143
+ return this.role === "anthropic" ? ARCHITECT_REVIEW_SYSTEM : ENGINEER_REVIEW_SYSTEM
144
+ }
145
+
146
+ private get critiqueSystem(): string {
147
+ return this.role === "anthropic" ? CRITIQUE_SYSTEM : CRITIQUE_RESPONSE_SYSTEM
148
+ }
149
+
150
+ async review(req: ReviewRequest): Promise<{ review: ModelReview; usage: TokenUsage }> {
151
+ const contextBlock = buildContextBlock(req)
152
+
153
+ const response = await this.call(
154
+ this.reviewSystem,
155
+ `Review this pull request:\n\n${contextBlock}\n\n${req.userPrompt}`
156
+ )
157
+
158
+ const parsed = parseModelReview(response.text)
159
+ return {
160
+ review: normalizeReview(parsed, this.role),
161
+ usage: response.usage,
162
+ }
163
+ }
164
+
165
+ async critique(req: CritiqueRequest): Promise<{ critique: CritiqueOutput; usage: TokenUsage }> {
166
+ const findingsJson = JSON.stringify(req.otherModelReview, null, 2)
167
+
168
+ const response = await this.call(
169
+ CRITIQUE_SYSTEM,
170
+ `Here are the findings from another code reviewer:\n\n${findingsJson}\n\nCritique these findings. What did they get right? What did they get wrong? What did they miss?`
171
+ )
172
+
173
+ const parsed = parseCritiqueOutput(response.text)
174
+ return {
175
+ critique: {
176
+ agreedFindings: parsed.agreed_findings,
177
+ disputedFindings: parsed.disputed_findings,
178
+ missedIssues: parsed.missed_issues.map((f) => normalizeFinding(f, this.role)),
179
+ overallAssessment: parsed.overall_assessment,
180
+ revisedSeverity: parsed.revised_severity,
181
+ },
182
+ usage: response.usage,
183
+ }
184
+ }
185
+
186
+ async respondToCritique(req: CritiqueResponseRequest): Promise<{
187
+ response: CritiqueResponse
188
+ usage: TokenUsage
189
+ }> {
190
+ const critiqueJson = JSON.stringify(req.critique, null, 2)
191
+ const originalJson = JSON.stringify(req.originalReview, null, 2)
192
+
193
+ const response = await this.call(
194
+ CRITIQUE_RESPONSE_SYSTEM,
195
+ `Your original review:\n${originalJson}\n\nA senior reviewer critiqued your findings:\n${critiqueJson}\n\nRespond to the critique. Accept valid points. Defend findings you believe are correct. Output revised findings.`
196
+ )
197
+
198
+ let parsed
199
+ try {
200
+ parsed = parseCritiqueResponse(response.text)
201
+ } catch {
202
+ return {
203
+ response: {
204
+ accepted: [],
205
+ disputed: [],
206
+ revisedFindings: req.originalReview.findings,
207
+ finalSummary: response.text.substring(0, 500),
208
+ },
209
+ usage: response.usage,
210
+ }
211
+ }
212
+
213
+ return {
214
+ response: {
215
+ accepted: parsed.accepted,
216
+ disputed: parsed.disputed,
217
+ revisedFindings: parsed.revised_findings.map((f) => normalizeFinding(f, this.role)),
218
+ finalSummary: parsed.final_summary,
219
+ },
220
+ usage: response.usage,
221
+ }
222
+ }
223
+
224
+ async chat(system: string, user: string): Promise<{ text: string; usage: TokenUsage }> {
225
+ return this.call(system, user)
226
+ }
227
+
228
+ private async call(
229
+ system: string,
230
+ user: string,
231
+ retries = 1
232
+ ): Promise<{ text: string; usage: TokenUsage }> {
233
+ for (let attempt = 0; attempt <= retries; attempt++) {
234
+ try {
235
+ const response = await this.client.chat.completions.create({
236
+ model: this.model,
237
+ messages: [
238
+ { role: "system", content: system },
239
+ { role: "user", content: user },
240
+ ],
241
+ max_tokens: 4096,
242
+ temperature: 0.1,
243
+ response_format: { type: "json_object" },
244
+ })
245
+
246
+ const text = response.choices[0]?.message?.content || ""
247
+ return {
248
+ text,
249
+ usage: {
250
+ input: response.usage?.prompt_tokens || 0,
251
+ output: response.usage?.completion_tokens || 0,
252
+ },
253
+ }
254
+ } catch (err: unknown) {
255
+ const status = (err as { status?: number }).status
256
+ const isRetryable = status === 429 || status === 500 || status === 502 || status === 503
257
+
258
+ if (attempt < retries && isRetryable) {
259
+ core.warning(`OpenRouter call failed (attempt ${attempt + 1}, model=${this.model}), retrying...`)
260
+ await sleep(2000 * (attempt + 1))
261
+ continue
262
+ }
263
+ throw err
264
+ }
265
+ }
266
+
267
+ throw new Error(`OpenRouter call exhausted retries (model=${this.model})`)
268
+ }
269
+ }
270
+
271
+ function buildContextBlock(req: ReviewRequest): string {
272
+ const parts: string[] = []
273
+ const ctx = req.context
274
+
275
+ if (ctx.repoPolicies.reviewRulesMarkdown) {
276
+ parts.push(`## Repository Review Rules\n${ctx.repoPolicies.reviewRulesMarkdown}`)
277
+ }
278
+ if (ctx.repoPolicies.architectureNotes) {
279
+ parts.push(`## Architecture Notes\n${ctx.repoPolicies.architectureNotes}`)
280
+ }
281
+
282
+ return parts.join("\n\n")
283
+ }
284
+
285
+ function normalizeReview(
286
+ parsed: ReturnType<typeof parseModelReview>,
287
+ source: "anthropic" | "openai"
288
+ ): ModelReview {
289
+ return {
290
+ summary: parsed.summary,
291
+ severity: parsed.severity,
292
+ confidence: parsed.confidence,
293
+ findings: parsed.findings.map((f) => normalizeFinding(f, source)),
294
+ mergeBlocking: parsed.merge_blocking,
295
+ needsHumanAttention: parsed.needs_human_attention,
296
+ }
297
+ }
298
+
299
+ function normalizeFinding(
300
+ f: { type: string; severity: string; title: string; file: string; line_start?: number; line_end?: number; explanation: string; suggested_fix?: string; confidence: number },
301
+ source: "anthropic" | "openai"
302
+ ): ReviewFinding {
303
+ return {
304
+ type: f.type as ReviewFinding["type"],
305
+ severity: f.severity as ReviewFinding["severity"],
306
+ title: f.title,
307
+ file: f.file,
308
+ lineStart: f.line_start,
309
+ lineEnd: f.line_end,
310
+ explanation: f.explanation,
311
+ suggestedFix: f.suggested_fix,
312
+ confidence: f.confidence,
313
+ source,
314
+ }
315
+ }
316
+
317
+ function sleep(ms: number): Promise<void> {
318
+ return new Promise((resolve) => setTimeout(resolve, ms))
319
+ }
@@ -0,0 +1,35 @@
1
+ import type { ModelReview, CritiqueOutput, CritiqueResponse, ReviewContext, TokenUsage } from "../types"
2
+
3
+ export interface ReviewRequest {
4
+ context: ReviewContext
5
+ systemPrompt: string
6
+ userPrompt: string
7
+ }
8
+
9
+ export interface CritiqueRequest {
10
+ context: ReviewContext
11
+ otherModelReview: ModelReview
12
+ systemPrompt: string
13
+ }
14
+
15
+ export interface CritiqueResponseRequest {
16
+ context: ReviewContext
17
+ critique: CritiqueOutput
18
+ originalReview: ModelReview
19
+ systemPrompt: string
20
+ }
21
+
22
+ export interface ModelClient {
23
+ name: "anthropic" | "openai"
24
+
25
+ review(req: ReviewRequest): Promise<{ review: ModelReview; usage: TokenUsage }>
26
+
27
+ critique(req: CritiqueRequest): Promise<{ critique: CritiqueOutput; usage: TokenUsage }>
28
+
29
+ respondToCritique(req: CritiqueResponseRequest): Promise<{
30
+ response: CritiqueResponse
31
+ usage: TokenUsage
32
+ }>
33
+
34
+ chat(system: string, user: string): Promise<{ text: string; usage: TokenUsage }>
35
+ }
@@ -0,0 +1,287 @@
1
+ import * as core from "@actions/core"
2
+ import type { ModelClient } from "./models/types"
3
+ import type {
4
+ ReviewContext,
5
+ ReviewFinding,
6
+ ModelReview,
7
+ FinalDecision,
8
+ FinalAction,
9
+ FindingSeverity,
10
+ TokenUsage,
11
+ ChangedFile,
12
+ } from "./types"
13
+
14
+ const SEVERITY_RANK: Record<FindingSeverity, number> = {
15
+ low: 0,
16
+ medium: 1,
17
+ high: 2,
18
+ critical: 3,
19
+ }
20
+
21
+ export async function orchestrateReview(
22
+ ctx: ReviewContext,
23
+ anthropic: ModelClient | null,
24
+ openai: ModelClient | null
25
+ ): Promise<FinalDecision> {
26
+ const start = Date.now()
27
+ const usage = {
28
+ anthropic: { input: 0, output: 0 },
29
+ openai: { input: 0, output: 0 },
30
+ }
31
+
32
+ if (!anthropic && !openai) {
33
+ return failedDecision("Both model clients unavailable", start)
34
+ }
35
+
36
+ const userPrompt = buildReviewPrompt(ctx)
37
+
38
+ // ── Phase 1: Independent analysis (parallel) ──
39
+
40
+ core.info("Phase 1: Independent analysis")
41
+
42
+ const [anthropicResult, openaiResult] = await Promise.allSettled([
43
+ anthropic?.review({
44
+ context: ctx,
45
+ systemPrompt: "",
46
+ userPrompt,
47
+ }),
48
+ openai?.review({
49
+ context: ctx,
50
+ systemPrompt: "",
51
+ userPrompt,
52
+ }),
53
+ ])
54
+
55
+ let anthropicReview: ModelReview | null = null
56
+ let openaiReview: ModelReview | null = null
57
+
58
+ if (anthropicResult.status === "fulfilled" && anthropicResult.value) {
59
+ anthropicReview = anthropicResult.value.review
60
+ addUsage(usage.anthropic, anthropicResult.value.usage)
61
+ core.info(`Anthropic review: ${anthropicReview.findings.length} findings, severity=${anthropicReview.severity}`)
62
+ } else {
63
+ const reason = anthropicResult.status === "rejected" ? anthropicResult.reason : "not configured"
64
+ core.warning(`Anthropic review failed: ${reason}`)
65
+ }
66
+
67
+ if (openaiResult.status === "fulfilled" && openaiResult.value) {
68
+ openaiReview = openaiResult.value.review
69
+ addUsage(usage.openai, openaiResult.value.usage)
70
+ core.info(`OpenAI review: ${openaiReview.findings.length} findings, severity=${openaiReview.severity}`)
71
+ } else {
72
+ const reason = openaiResult.status === "rejected" ? openaiResult.reason : "not configured"
73
+ core.warning(`OpenAI review failed: ${reason}`)
74
+ }
75
+
76
+ if (!anthropicReview && !openaiReview) {
77
+ return failedDecision("Both models failed to produce reviews", start)
78
+ }
79
+
80
+ // ── Single-model fallback ──
81
+
82
+ if (!anthropicReview || !openaiReview) {
83
+ const singleReview = anthropicReview || openaiReview!
84
+ const source = anthropicReview ? "anthropic" : "openai"
85
+ core.warning(`Running single-model mode (${source} only)`)
86
+
87
+ return {
88
+ action: determineAction(singleReview.findings, ctx),
89
+ rationale: `Single-model review (${source}): ${singleReview.summary}`,
90
+ findings: singleReview.findings,
91
+ anthropicReview: anthropicReview || undefined,
92
+ openaiReview: openaiReview || undefined,
93
+ tokenUsage: usage,
94
+ durationMs: Date.now() - start,
95
+ }
96
+ }
97
+
98
+ // ── Phase 2: Anthropic critiques OpenAI's findings ──
99
+
100
+ core.info("Phase 2: Anthropic critiques OpenAI findings")
101
+
102
+ let critique = null
103
+ try {
104
+ const critiqueResult = await anthropic!.critique({
105
+ context: ctx,
106
+ otherModelReview: openaiReview,
107
+ systemPrompt: "",
108
+ })
109
+ critique = critiqueResult.critique
110
+ addUsage(usage.anthropic, critiqueResult.usage)
111
+ core.info(`Critique: ${critique.agreedFindings.length} agreed, ${critique.disputedFindings.length} disputed, ${critique.missedIssues.length} missed`)
112
+ } catch (err) {
113
+ core.warning(`Critique phase failed: ${err}`)
114
+ }
115
+
116
+ // ── Phase 3: OpenAI responds to critique ──
117
+
118
+ let critiqueResponse = null
119
+ if (critique) {
120
+ core.info("Phase 3: OpenAI responds to critique")
121
+
122
+ try {
123
+ const responseResult = await openai!.respondToCritique({
124
+ context: ctx,
125
+ critique,
126
+ originalReview: openaiReview,
127
+ systemPrompt: "",
128
+ })
129
+ critiqueResponse = responseResult.response
130
+ addUsage(usage.openai, responseResult.usage)
131
+ core.info(`Response: ${critiqueResponse.accepted.length} accepted, ${critiqueResponse.disputed.length} disputed`)
132
+ } catch (err) {
133
+ core.warning(`Critique response phase failed: ${err}`)
134
+ }
135
+ }
136
+
137
+ // ── Phase 4: Synthesis ──
138
+
139
+ core.info("Phase 4: Merging findings")
140
+
141
+ const mergedFindings = mergeFindings(
142
+ anthropicReview,
143
+ openaiReview,
144
+ critique,
145
+ critiqueResponse
146
+ )
147
+
148
+ const action = determineAction(mergedFindings, ctx)
149
+
150
+ return {
151
+ action,
152
+ rationale: buildRationale(anthropicReview, openaiReview, critique, mergedFindings),
153
+ findings: mergedFindings,
154
+ anthropicReview,
155
+ openaiReview,
156
+ critique: critique || undefined,
157
+ critiqueResponse: critiqueResponse || undefined,
158
+ tokenUsage: usage,
159
+ durationMs: Date.now() - start,
160
+ }
161
+ }
162
+
163
+ function buildReviewPrompt(ctx: ReviewContext): string {
164
+ const pr = ctx.pullRequest
165
+ if (!pr) return "No PR context available."
166
+
167
+ const parts: string[] = []
168
+
169
+ parts.push(`# PR #${pr.number}: ${pr.title}`)
170
+ if (pr.body) parts.push(`\n## Description\n${pr.body.substring(0, 2000)}`)
171
+ parts.push(`\nBase: ${pr.baseRef} ← Head: ${pr.headRef}`)
172
+ if (pr.labels.length) parts.push(`Labels: ${pr.labels.join(", ")}`)
173
+ if (pr.ciStatus) parts.push(`CI Status: ${pr.ciStatus}`)
174
+
175
+ parts.push(`\n## Changed Files (${pr.changedFiles.length})`)
176
+
177
+ for (const file of pr.changedFiles) {
178
+ parts.push(`\n### ${file.path} (${file.status}, +${file.additions}/-${file.deletions})`)
179
+ if (file.patch) {
180
+ const truncated = file.patch.length > 3000
181
+ ? file.patch.substring(0, 3000) + "\n... (truncated)"
182
+ : file.patch
183
+ parts.push(`\`\`\`diff\n${truncated}\n\`\`\``)
184
+ }
185
+ }
186
+
187
+ return parts.join("\n")
188
+ }
189
+
190
+ function mergeFindings(
191
+ anthropicReview: ModelReview,
192
+ openaiReview: ModelReview,
193
+ critique: { agreedFindings: string[]; disputedFindings: Array<{ finding: string; reason: string }>; missedIssues: ReviewFinding[] } | null,
194
+ critiqueResponse: { revisedFindings: ReviewFinding[] } | null
195
+ ): ReviewFinding[] {
196
+ const merged: ReviewFinding[] = []
197
+ const seen = new Set<string>()
198
+
199
+ for (const finding of anthropicReview.findings) {
200
+ const key = dedupeKey(finding)
201
+ if (!seen.has(key)) {
202
+ seen.add(key)
203
+ merged.push(finding)
204
+ }
205
+ }
206
+
207
+ const openaiSource = critiqueResponse?.revisedFindings || openaiReview.findings
208
+ for (const finding of openaiSource) {
209
+ const key = dedupeKey(finding)
210
+ if (!seen.has(key)) {
211
+ seen.add(key)
212
+ merged.push(finding)
213
+ }
214
+ }
215
+
216
+ if (critique?.missedIssues) {
217
+ for (const finding of critique.missedIssues) {
218
+ const key = dedupeKey(finding)
219
+ if (!seen.has(key)) {
220
+ seen.add(key)
221
+ merged.push(finding)
222
+ }
223
+ }
224
+ }
225
+
226
+ merged.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
227
+
228
+ return merged
229
+ }
230
+
231
+ function dedupeKey(f: ReviewFinding): string {
232
+ return `${f.file}:${f.lineStart || 0}:${f.type}:${f.title.toLowerCase().substring(0, 40)}`
233
+ }
234
+
235
+ function determineAction(findings: ReviewFinding[], ctx: ReviewContext): FinalAction {
236
+ if (findings.length === 0) return "comment_only"
237
+
238
+ const hasCritical = findings.some((f) => f.severity === "critical")
239
+ const hasHigh = findings.some((f) => f.severity === "high")
240
+ const hasSecurity = findings.some((f) => f.type === "security" && SEVERITY_RANK[f.severity] >= SEVERITY_RANK["high"])
241
+
242
+ const restrictedTouched = ctx.pullRequest?.changedFiles.some((file: ChangedFile) =>
243
+ ctx.repoPolicies.restrictedPaths.some((pattern: string) => fileMatchesPattern(file.path, pattern))
244
+ )
245
+
246
+ if (hasSecurity || (hasCritical && restrictedTouched)) return "needs_human_review"
247
+ if (hasCritical || hasHigh) return "request_changes"
248
+ return "comment_only"
249
+ }
250
+
251
+ function fileMatchesPattern(filePath: string, pattern: string): boolean {
252
+ const regex = pattern
253
+ .replace(/\./g, "\\.")
254
+ .replace(/\*\*/g, "{{GLOBSTAR}}")
255
+ .replace(/\*/g, "[^/]*")
256
+ .replace(/\{\{GLOBSTAR\}\}/g, ".*")
257
+ return new RegExp(`^${regex}$`).test(filePath)
258
+ }
259
+
260
+ function buildRationale(
261
+ anthropicReview: ModelReview,
262
+ openaiReview: ModelReview,
263
+ critique: { overallAssessment: string } | null,
264
+ findings: ReviewFinding[]
265
+ ): string {
266
+ const parts: string[] = []
267
+ parts.push(`Anthropic: ${anthropicReview.summary}`)
268
+ parts.push(`OpenAI: ${openaiReview.summary}`)
269
+ if (critique) parts.push(`Synthesis: ${critique.overallAssessment}`)
270
+ parts.push(`Merged: ${findings.length} findings`)
271
+ return parts.join(" | ")
272
+ }
273
+
274
+ function failedDecision(reason: string, startTime: number): FinalDecision {
275
+ return {
276
+ action: "decline",
277
+ rationale: reason,
278
+ findings: [],
279
+ tokenUsage: { anthropic: { input: 0, output: 0 }, openai: { input: 0, output: 0 } },
280
+ durationMs: Date.now() - startTime,
281
+ }
282
+ }
283
+
284
+ function addUsage(target: TokenUsage, source: TokenUsage): void {
285
+ target.input += source.input
286
+ target.output += source.output
287
+ }