@formthefog/stratus 2026.2.20 โ†’ 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 (76) 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/publish.yml +28 -0
  65. package/.github/workflows/sentinel.yml +55 -0
  66. package/README.md +90 -41
  67. package/SECURITY.md +85 -0
  68. package/TROUBLESHOOTING.md +2 -2
  69. package/index.ts +219 -109
  70. package/openclaw.plugin.json +50 -26
  71. package/package.json +1 -1
  72. package/skills/stratus-info/SKILL.md +70 -10
  73. package/src/client.ts +78 -18
  74. package/src/config.ts +29 -8
  75. package/src/setup.ts +53 -61
  76. package/src/types.ts +11 -0
@@ -0,0 +1,666 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import type { FinalDecision, FinalAction, ReviewFinding, FindingSeverity, FindingType, FixResult, FixMode } from "./types"
4
+
5
+ type Octokit = ReturnType<typeof github.getOctokit>
6
+
7
+ const COMMENT_MARKER = "<!-- sentinel-review -->"
8
+
9
+ const SEVERITY_EMOJI: Record<FindingSeverity, string> = {
10
+ critical: "๐Ÿ”ด",
11
+ high: "๐Ÿ”ด",
12
+ medium: "๐ŸŸก",
13
+ low: "๐Ÿ”ต",
14
+ }
15
+
16
+ const SEVERITY_LABEL: Record<FindingSeverity, string> = {
17
+ critical: "CRITICAL",
18
+ high: "HIGH",
19
+ medium: "MEDIUM",
20
+ low: "LOW",
21
+ }
22
+
23
+ const TYPE_LABEL: Record<FindingType, string> = {
24
+ bug: "Bug",
25
+ security: "Security",
26
+ performance: "Performance",
27
+ maintainability: "Maintainability",
28
+ test_gap: "Test Gap",
29
+ architecture: "Architecture",
30
+ }
31
+
32
+ const SOURCE_LABEL: Record<string, string> = {
33
+ anthropic: "Anthropic",
34
+ openai: "OpenAI",
35
+ merged: "Both models",
36
+ }
37
+
38
+ const REVIEW_EVENT_MAP: Partial<Record<FinalAction, "COMMENT" | "REQUEST_CHANGES" | "APPROVE">> = {
39
+ comment_only: "COMMENT",
40
+ request_changes: "REQUEST_CHANGES",
41
+ needs_human_review: "REQUEST_CHANGES",
42
+ suggest_patch: "COMMENT",
43
+ decline: "COMMENT",
44
+ }
45
+
46
+ export async function reportReview(
47
+ octokit: Octokit,
48
+ decision: FinalDecision,
49
+ prNumber: number,
50
+ summaryOnClean = false
51
+ ): Promise<void> {
52
+ const { owner, repo } = github.context.repo
53
+ const inlineFindings = decision.findings.filter((f) => f.lineStart && f.lineStart > 0)
54
+ const nonInlineFindings = decision.findings.filter((f) => !f.lineStart || f.lineStart <= 0)
55
+
56
+ await dismissPreviousReviews(octokit, prNumber)
57
+
58
+ if (inlineFindings.length > 0) {
59
+ await submitPRReview(octokit, decision, prNumber, inlineFindings)
60
+ }
61
+
62
+ if (decision.findings.length === 0 && !summaryOnClean) {
63
+ // Still emit step summary + action outputs even when skipping the PR comment,
64
+ // so downstream jobs and RL training can consume quality_score / findings_count.
65
+ await postStepSummary(decision)
66
+ setOutputs(decision)
67
+ core.info("No findings โ€” skipping summary comment (summary_on_clean is false)")
68
+ return
69
+ }
70
+
71
+ const summaryBody = formatSummaryComment(decision, nonInlineFindings, inlineFindings.length)
72
+ await upsertComment(octokit, prNumber, summaryBody)
73
+
74
+ await postStepSummary(decision)
75
+ setOutputs(decision)
76
+
77
+ core.info(`Review posted: ${decision.action}, ${decision.findings.length} findings (${inlineFindings.length} inline, ${nonInlineFindings.length} summary)`)
78
+ }
79
+
80
+ export async function reportIssueTriage(
81
+ octokit: Octokit,
82
+ issueNumber: number,
83
+ classification: string,
84
+ summary: string
85
+ ): Promise<void> {
86
+ const { owner, repo } = github.context.repo
87
+ const body = [
88
+ COMMENT_MARKER,
89
+ "## Sentinel โ€” Issue Triage",
90
+ "",
91
+ `**Classification:** ${classification}`,
92
+ "",
93
+ summary,
94
+ "",
95
+ "---",
96
+ "*Triaged by Sentinel*",
97
+ ].join("\n")
98
+
99
+ await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
100
+ }
101
+
102
+ export async function reportFixResult(
103
+ octokit: Octokit,
104
+ issueNumber: number,
105
+ result: FixResult,
106
+ mode: FixMode
107
+ ): Promise<void> {
108
+ const { owner, repo } = github.context.repo
109
+ const lines: string[] = [COMMENT_MARKER]
110
+
111
+ if (result.success && result.fixPlan) {
112
+ const plan = result.fixPlan
113
+ const modeLabel =
114
+ mode === "propose_only" ? "Proposed Fix" :
115
+ mode === "propose_and_pr" ? "Fix PR Created" :
116
+ "Fix Applied (Auto-merged)"
117
+
118
+ lines.push(`## Sentinel โ€” ${modeLabel} โœ…`)
119
+ lines.push("")
120
+ lines.push(`**Confidence:** ${(plan.confidence * 100).toFixed(0)}%`)
121
+ lines.push("")
122
+ lines.push("### Analysis")
123
+ lines.push(plan.analysis)
124
+ lines.push("")
125
+ lines.push("### Changes")
126
+
127
+ for (const file of plan.files) {
128
+ lines.push("")
129
+ lines.push(`#### \`${file.path}\` (${file.action})`)
130
+ lines.push(file.explanation)
131
+
132
+ if (file.changes && file.changes.length > 0) {
133
+ lines.push("")
134
+ lines.push("<details>")
135
+ lines.push("<summary>View changes</summary>")
136
+ lines.push("")
137
+ for (const change of file.changes) {
138
+ lines.push("```diff")
139
+ lines.push(`- ${change.search.split("\n").join("\n- ")}`)
140
+ lines.push(`+ ${change.replace.split("\n").join("\n+ ")}`)
141
+ lines.push("```")
142
+ }
143
+ lines.push("")
144
+ lines.push("</details>")
145
+ }
146
+ }
147
+
148
+ if (plan.testSuggestions.length > 0) {
149
+ lines.push("")
150
+ lines.push("### Suggested Tests")
151
+ for (const t of plan.testSuggestions) lines.push(`- [ ] ${t}`)
152
+ }
153
+
154
+ if (plan.riskNotes.length > 0) {
155
+ lines.push("")
156
+ lines.push("### Risk Notes")
157
+ for (const r of plan.riskNotes) lines.push(`- โš ๏ธ ${r}`)
158
+ }
159
+
160
+ if (result.prUrl) {
161
+ lines.push("")
162
+ lines.push(`### Pull Request`)
163
+ lines.push(`โ†’ ${result.prUrl}`)
164
+ }
165
+ } else {
166
+ lines.push("## Sentinel โ€” Fix Analysis โ„น๏ธ")
167
+ lines.push("")
168
+ lines.push(`Could not generate a fix: ${result.error}`)
169
+
170
+ if (result.fixPlan) {
171
+ lines.push("")
172
+ lines.push("### Analysis")
173
+ lines.push(result.fixPlan.analysis)
174
+
175
+ if (result.fixPlan.confidence > 0) {
176
+ lines.push("")
177
+ lines.push(`Confidence was ${(result.fixPlan.confidence * 100).toFixed(0)}% (below threshold).`)
178
+ }
179
+ }
180
+ }
181
+
182
+ lines.push("")
183
+ lines.push("---")
184
+ lines.push("*Did we get this right? ๐Ÿ‘ / ๐Ÿ‘Ž to inform future fixes*")
185
+ lines.push("")
186
+ lines.push("*Sentinel*")
187
+
188
+ const body = lines.join("\n")
189
+ await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
190
+ }
191
+
192
+ export async function reportFailure(
193
+ octokit: Octokit,
194
+ prOrIssueNumber: number,
195
+ error: string
196
+ ): Promise<void> {
197
+ const body = [
198
+ COMMENT_MARKER,
199
+ "## Sentinel โ€” Review Failed",
200
+ "",
201
+ `Review could not be completed: ${error}`,
202
+ "",
203
+ "This is non-destructive โ€” no code was modified.",
204
+ "",
205
+ "---",
206
+ "*Sentinel*",
207
+ ].join("\n")
208
+
209
+ try {
210
+ await upsertComment(octokit, prOrIssueNumber, body)
211
+ } catch {
212
+ core.warning("Failed to post failure comment")
213
+ }
214
+ }
215
+
216
+ // โ”€โ”€ PR Review (inline comments with resolve/unresolve) โ”€โ”€
217
+
218
+ async function submitPRReview(
219
+ octokit: Octokit,
220
+ decision: FinalDecision,
221
+ prNumber: number,
222
+ inlineFindings: ReviewFinding[]
223
+ ): Promise<void> {
224
+ const { owner, repo } = github.context.repo
225
+
226
+ try {
227
+ const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
228
+ const commitSha = pr.head.sha
229
+ const event = REVIEW_EVENT_MAP[decision.action] || "COMMENT"
230
+
231
+ const comments = inlineFindings.slice(0, 25).map((f) => {
232
+ const comment: {
233
+ path: string
234
+ body: string
235
+ line: number
236
+ side: "RIGHT"
237
+ start_line?: number
238
+ start_side?: "RIGHT"
239
+ } = {
240
+ path: f.file,
241
+ body: formatFindingComment(f),
242
+ line: f.lineEnd && f.lineEnd !== f.lineStart ? f.lineEnd : f.lineStart!,
243
+ side: "RIGHT",
244
+ }
245
+
246
+ if (f.lineEnd && f.lineEnd !== f.lineStart && f.lineStart) {
247
+ comment.start_line = f.lineStart
248
+ comment.start_side = "RIGHT"
249
+ }
250
+
251
+ return comment
252
+ })
253
+
254
+ await octokit.rest.pulls.createReview({
255
+ owner,
256
+ repo,
257
+ pull_number: prNumber,
258
+ commit_id: commitSha,
259
+ event,
260
+ body: `Sentinel reviewed this PR with ${decision.findings.length} finding(s).`,
261
+ comments,
262
+ })
263
+
264
+ core.info(`Submitted PR review: ${event}, ${comments.length} inline comments`)
265
+ } catch (err) {
266
+ core.warning(`PR review submission failed, falling back to individual comments: ${err}`)
267
+ await postIndividualComments(octokit, inlineFindings, prNumber)
268
+ }
269
+ }
270
+
271
+ async function postIndividualComments(
272
+ octokit: Octokit,
273
+ findings: ReviewFinding[],
274
+ prNumber: number
275
+ ): Promise<void> {
276
+ const { owner, repo } = github.context.repo
277
+
278
+ try {
279
+ const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
280
+ const commitSha = pr.head.sha
281
+
282
+ for (const f of findings.slice(0, 15)) {
283
+ try {
284
+ await octokit.rest.pulls.createReviewComment({
285
+ owner,
286
+ repo,
287
+ pull_number: prNumber,
288
+ commit_id: commitSha,
289
+ path: f.file,
290
+ line: f.lineStart!,
291
+ body: formatFindingComment(f),
292
+ })
293
+ } catch {
294
+ core.debug(`Could not post inline comment on ${f.file}:${f.lineStart}`)
295
+ }
296
+ }
297
+ } catch (err) {
298
+ core.debug(`Inline comments skipped entirely: ${err}`)
299
+ }
300
+ }
301
+
302
+ async function dismissPreviousReviews(octokit: Octokit, prNumber: number): Promise<void> {
303
+ const { owner, repo } = github.context.repo
304
+
305
+ try {
306
+ const { data: reviews } = await octokit.rest.pulls.listReviews({
307
+ owner,
308
+ repo,
309
+ pull_number: prNumber,
310
+ })
311
+
312
+ for (const review of reviews) {
313
+ if (
314
+ review.body?.includes("Sentinel") &&
315
+ review.state === "CHANGES_REQUESTED"
316
+ ) {
317
+ try {
318
+ await octokit.rest.pulls.dismissReview({
319
+ owner,
320
+ repo,
321
+ pull_number: prNumber,
322
+ review_id: review.id,
323
+ message: "Superseded by new Sentinel review",
324
+ })
325
+ } catch {
326
+ core.debug(`Could not dismiss review ${review.id}`)
327
+ }
328
+ }
329
+ }
330
+ } catch {
331
+ core.debug("Could not list previous reviews for dismissal")
332
+ }
333
+ }
334
+
335
+ // โ”€โ”€ Per-Finding Rich Comment (Sentry-style) โ”€โ”€
336
+
337
+ function formatFindingComment(f: ReviewFinding): string {
338
+ const lines: string[] = []
339
+
340
+ const typeLabel = TYPE_LABEL[f.type] || f.type
341
+ lines.push(`**${typeLabel}:** ${f.explanation}`)
342
+ lines.push("")
343
+
344
+ const sevEmoji = SEVERITY_EMOJI[f.severity]
345
+ const sevLabel = SEVERITY_LABEL[f.severity]
346
+ const source = SOURCE_LABEL[f.source] || f.source
347
+ const confidence = Math.round(f.confidence * 100)
348
+ lines.push(`**Severity:** ${sevEmoji} ${sevLabel} ยท **Confidence:** ${confidence}% ยท **Source:** ${source}`)
349
+
350
+ if (f.suggestedFix) {
351
+ lines.push("")
352
+ lines.push("<details>")
353
+ lines.push("<summary>Suggested Fix</summary>")
354
+ lines.push("")
355
+ lines.push("```suggestion")
356
+ lines.push(f.suggestedFix)
357
+ lines.push("```")
358
+ lines.push("")
359
+ lines.push("</details>")
360
+ }
361
+
362
+ lines.push("")
363
+ lines.push("<details>")
364
+ lines.push("<summary>Prompt for AI Agent</summary>")
365
+ lines.push("")
366
+ lines.push("```")
367
+ lines.push(buildAgentPrompt(f))
368
+ lines.push("```")
369
+ lines.push("")
370
+ lines.push("</details>")
371
+
372
+ lines.push("")
373
+ lines.push("---")
374
+ lines.push("*Did we get this right? ๐Ÿ‘ / ๐Ÿ‘Ž to inform future reviews*")
375
+
376
+ return lines.join("\n")
377
+ }
378
+
379
+ function buildAgentPrompt(f: ReviewFinding): string {
380
+ const typeLabel = TYPE_LABEL[f.type] || f.type
381
+ const location = f.lineStart
382
+ ? `${f.file}#L${f.lineStart}${f.lineEnd && f.lineEnd !== f.lineStart ? `-L${f.lineEnd}` : ""}`
383
+ : f.file
384
+
385
+ const parts: string[] = []
386
+ parts.push("Review the code at the location below. A potential issue has been")
387
+ parts.push("identified by an AI agent.")
388
+ parts.push("Verify if this is a real issue. If it is, propose a fix; if not,")
389
+ parts.push("explain why it's not valid.")
390
+ parts.push("")
391
+ parts.push(`Location: ${location}`)
392
+ parts.push(`Type: ${typeLabel}`)
393
+ parts.push("")
394
+ parts.push(`Potential issue: ${f.explanation}`)
395
+
396
+ if (f.suggestedFix) {
397
+ parts.push("")
398
+ parts.push(`Suggested fix: ${f.suggestedFix}`)
399
+ }
400
+
401
+ return parts.join("\n")
402
+ }
403
+
404
+ // โ”€โ”€ Summary Comment (upserted) โ”€โ”€
405
+
406
+ function formatSummaryComment(
407
+ decision: FinalDecision,
408
+ nonInlineFindings: ReviewFinding[],
409
+ inlineCount: number
410
+ ): string {
411
+ const lines: string[] = [COMMENT_MARKER]
412
+
413
+ const statusEmoji =
414
+ decision.action === "comment_only" ? "โœ…" :
415
+ decision.action === "request_changes" ? "โš ๏ธ" :
416
+ decision.action === "needs_human_review" ? "๐Ÿ›‘" : "โ„น๏ธ"
417
+
418
+ lines.push(`## Sentinel Review ${statusEmoji}`)
419
+ lines.push("")
420
+
421
+ if (decision.findings.length === 0) {
422
+ lines.push("No issues found. Code looks clean. โœจ")
423
+ } else {
424
+ const severityCounts = countBySeverity(decision.findings)
425
+ const countParts: string[] = []
426
+ if (severityCounts.critical) countParts.push(`๐Ÿ”ด ${severityCounts.critical} critical`)
427
+ if (severityCounts.high) countParts.push(`๐Ÿ”ด ${severityCounts.high} high`)
428
+ if (severityCounts.medium) countParts.push(`๐ŸŸก ${severityCounts.medium} medium`)
429
+ if (severityCounts.low) countParts.push(`๐Ÿ”ต ${severityCounts.low} low`)
430
+
431
+ lines.push(`**${decision.findings.length} finding(s)** โ€” ${countParts.join(" ยท ")}`)
432
+
433
+ if (inlineCount > 0) {
434
+ lines.push("")
435
+ lines.push(`> ${inlineCount} finding(s) posted as inline review comments on the diff.`)
436
+ }
437
+ }
438
+
439
+ if (nonInlineFindings.length > 0) {
440
+ lines.push("")
441
+ lines.push("### Findings without line context")
442
+ lines.push("")
443
+
444
+ for (const f of nonInlineFindings) {
445
+ const sevEmoji = SEVERITY_EMOJI[f.severity]
446
+ const typeLabel = TYPE_LABEL[f.type] || f.type
447
+ lines.push(`#### ${sevEmoji} ${f.title}`)
448
+ lines.push("")
449
+ lines.push(`**${typeLabel}** in \`${f.file}\` ยท **Severity:** ${SEVERITY_LABEL[f.severity]} ยท **Confidence:** ${Math.round(f.confidence * 100)}%`)
450
+ lines.push("")
451
+ lines.push(f.explanation)
452
+
453
+ if (f.suggestedFix) {
454
+ lines.push("")
455
+ lines.push("<details>")
456
+ lines.push("<summary>Suggested Fix</summary>")
457
+ lines.push("")
458
+ lines.push("```suggestion")
459
+ lines.push(f.suggestedFix)
460
+ lines.push("```")
461
+ lines.push("")
462
+ lines.push("</details>")
463
+ }
464
+
465
+ lines.push("")
466
+ lines.push("<details>")
467
+ lines.push("<summary>Prompt for AI Agent</summary>")
468
+ lines.push("")
469
+ lines.push("```")
470
+ lines.push(buildAgentPrompt(f))
471
+ lines.push("```")
472
+ lines.push("")
473
+ lines.push("</details>")
474
+ lines.push("")
475
+ }
476
+ }
477
+
478
+ if (decision.anthropicReview && decision.openaiReview) {
479
+ lines.push("")
480
+ lines.push("<details>")
481
+ lines.push("<summary>Model Summaries</summary>")
482
+ lines.push("")
483
+ lines.push(`**Anthropic (architecture):** ${decision.anthropicReview.summary}`)
484
+ lines.push("")
485
+ lines.push(`**OpenAI (implementation):** ${decision.openaiReview.summary}`)
486
+ lines.push("")
487
+ lines.push("</details>")
488
+ }
489
+
490
+ if (decision.critique) {
491
+ const { agreedFindings, disputedFindings } = decision.critique
492
+ if (agreedFindings.length || disputedFindings.length) {
493
+ lines.push("")
494
+ lines.push("<details>")
495
+ lines.push("<summary>Adversarial Debate</summary>")
496
+ lines.push("")
497
+ if (agreedFindings.length) {
498
+ lines.push("**Agreed:**")
499
+ for (const a of agreedFindings) lines.push(`- โœ“ ${a}`)
500
+ lines.push("")
501
+ }
502
+ if (disputedFindings.length) {
503
+ lines.push("**Disputed:**")
504
+ for (const d of disputedFindings) lines.push(`- โœ— ${d.finding}: *${d.reason}*`)
505
+ lines.push("")
506
+ }
507
+ lines.push("</details>")
508
+ }
509
+ }
510
+
511
+ lines.push("")
512
+ lines.push("---")
513
+ lines.push("")
514
+
515
+ const verdict =
516
+ decision.action === "comment_only" ? "No blockers โ€” ready to merge" :
517
+ decision.action === "request_changes" ? "Changes requested โ€” address findings before merge" :
518
+ decision.action === "needs_human_review" ? "Human review required โ€” sensitive changes detected" :
519
+ decision.action
520
+
521
+ lines.push(`**Verdict:** ${verdict}`)
522
+
523
+ const totalTokens =
524
+ decision.tokenUsage.anthropic.input + decision.tokenUsage.anthropic.output +
525
+ decision.tokenUsage.openai.input + decision.tokenUsage.openai.output
526
+ const seconds = (decision.durationMs / 1000).toFixed(1)
527
+
528
+ lines.push("")
529
+ lines.push(`*Sentinel โ€” dual-model review in ${seconds}s ยท ${totalTokens.toLocaleString()} tokens*`)
530
+
531
+ return lines.join("\n")
532
+ }
533
+
534
+ // โ”€โ”€ Helpers โ”€โ”€
535
+
536
+ function countBySeverity(findings: ReviewFinding[]): Record<FindingSeverity, number> {
537
+ const counts: Record<FindingSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 }
538
+ for (const f of findings) counts[f.severity]++
539
+ return counts
540
+ }
541
+
542
+ async function upsertComment(octokit: Octokit, issueNumber: number, body: string): Promise<void> {
543
+ const { owner, repo } = github.context.repo
544
+
545
+ try {
546
+ const { data: comments } = await octokit.rest.issues.listComments({
547
+ owner,
548
+ repo,
549
+ issue_number: issueNumber,
550
+ })
551
+
552
+ const existing = comments.find((c) => c.body?.includes(COMMENT_MARKER))
553
+ if (existing) {
554
+ await octokit.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
555
+ } else {
556
+ await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
557
+ }
558
+ } catch (err) {
559
+ core.warning(`Failed to upsert comment: ${err}`)
560
+ try {
561
+ const { owner, repo } = github.context.repo
562
+ await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
563
+ } catch {
564
+ core.warning("Failed to create comment as fallback")
565
+ }
566
+ }
567
+ }
568
+
569
+ async function postStepSummary(decision: FinalDecision): Promise<void> {
570
+ const lines: string[] = []
571
+ lines.push("### Sentinel Review")
572
+ lines.push("")
573
+ lines.push("| Metric | Value |")
574
+ lines.push("|--------|-------|")
575
+ lines.push(`| Findings | ${decision.findings.length} |`)
576
+ lines.push(`| Action | \`${decision.action}\` |`)
577
+ lines.push(`| Duration | ${(decision.durationMs / 1000).toFixed(1)}s |`)
578
+ lines.push(`| Anthropic tokens | ${(decision.tokenUsage.anthropic.input + decision.tokenUsage.anthropic.output).toLocaleString()} |`)
579
+ lines.push(`| OpenAI tokens | ${(decision.tokenUsage.openai.input + decision.tokenUsage.openai.output).toLocaleString()} |`)
580
+
581
+ if (decision.findings.length > 0) {
582
+ lines.push("")
583
+ lines.push("| Severity | Count |")
584
+ lines.push("|----------|-------|")
585
+ const counts = countBySeverity(decision.findings)
586
+ for (const [sev, count] of Object.entries(counts)) {
587
+ if (count > 0) lines.push(`| ${SEVERITY_EMOJI[sev as FindingSeverity]} ${SEVERITY_LABEL[sev as FindingSeverity]} | ${count} |`)
588
+ }
589
+ }
590
+
591
+ core.summary.addRaw(lines.join("\n"))
592
+ await core.summary.write()
593
+ }
594
+
595
+ function setOutputs(decision: FinalDecision): void {
596
+ const quality = computeQualitySignal(decision)
597
+
598
+ const artifact = {
599
+ version: 2,
600
+ timestamp: new Date().toISOString(),
601
+ action: decision.action,
602
+ rationale: decision.rationale,
603
+ findings: decision.findings,
604
+ token_usage: decision.tokenUsage,
605
+ duration_ms: decision.durationMs,
606
+ quality_score: quality.quality_score,
607
+ model_agreement: quality.model_agreement,
608
+ dimensions: quality.dimensions,
609
+ severity_counts: quality.severity_counts,
610
+ }
611
+
612
+ core.setOutput("review_json", JSON.stringify(artifact))
613
+ core.setOutput("findings_count", decision.findings.length.toString())
614
+ core.setOutput("action", decision.action)
615
+ core.setOutput("quality_score", quality.quality_score.toFixed(4))
616
+ core.setOutput("model_agreement", quality.model_agreement.toFixed(4))
617
+ core.setOutput("findings_critical", quality.severity_counts.critical.toString())
618
+ core.setOutput("findings_high", quality.severity_counts.high.toString())
619
+ core.setOutput("findings_medium", quality.severity_counts.medium.toString())
620
+ core.setOutput("findings_low", quality.severity_counts.low.toString())
621
+ core.setOutput("dim_correctness", quality.dimensions.correctness.toFixed(4))
622
+ core.setOutput("dim_coverage", quality.dimensions.coverage.toFixed(4))
623
+ core.setOutput("dim_architecture", quality.dimensions.architecture.toFixed(4))
624
+ core.setOutput("dim_value", quality.dimensions.value.toFixed(4))
625
+ }
626
+
627
+ interface QualitySignal {
628
+ quality_score: number
629
+ model_agreement: number
630
+ dimensions: { correctness: number; coverage: number; architecture: number; value: number }
631
+ severity_counts: Record<FindingSeverity, number>
632
+ }
633
+
634
+ function computeQualitySignal(decision: FinalDecision): QualitySignal {
635
+ const counts = countBySeverity(decision.findings)
636
+
637
+ const penalties = counts.critical * 0.25 + counts.high * 0.15 + counts.medium * 0.05 + counts.low * 0.01
638
+ const quality_score = Math.max(0, Math.min(1, 1.0 - penalties))
639
+
640
+ let model_agreement = 1.0
641
+ if (decision.anthropicReview && decision.openaiReview) {
642
+ const agreedCount = decision.critique?.agreedFindings?.length ?? 0
643
+ const disputedCount = decision.critique?.disputedFindings?.length ?? 0
644
+ const totalDebated = agreedCount + disputedCount
645
+ model_agreement = totalDebated > 0 ? agreedCount / totalDebated : 1.0
646
+ }
647
+
648
+ const dimPenalties: Record<FindingType, keyof QualitySignal["dimensions"]> = {
649
+ bug: "correctness",
650
+ security: "correctness",
651
+ performance: "value",
652
+ maintainability: "value",
653
+ test_gap: "coverage",
654
+ architecture: "architecture",
655
+ }
656
+
657
+ const dims = { correctness: 1.0, coverage: 1.0, architecture: 1.0, value: 1.0 }
658
+ for (const f of decision.findings) {
659
+ const dim = dimPenalties[f.type]
660
+ if (!dim) continue
661
+ const penalty = f.severity === "critical" ? 0.3 : f.severity === "high" ? 0.2 : f.severity === "medium" ? 0.1 : 0.03
662
+ dims[dim] = Math.max(0, dims[dim] - penalty)
663
+ }
664
+
665
+ return { quality_score, model_agreement, dimensions: dims, severity_counts: counts }
666
+ }