@formthefog/stratus 2026.2.24 → 2026.3.20

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 +255 -114
  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 +82 -61
  75. package/src/types.ts +11 -0
@@ -0,0 +1,353 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import { execSync } from "child_process"
4
+ import * as fs from "fs"
5
+ import type { ModelClient } from "./models/types"
6
+ import type { ReviewContext, FixMode, FixPlan, FixResult, CodeContext } from "./types"
7
+ import { analyzeCodebase, extractKeywords } from "./codebase"
8
+ import { parseFixPlan, parseFixReview } from "./schemas/fix"
9
+
10
+ type Octokit = ReturnType<typeof github.getOctokit>
11
+
12
+ const PLAN_SYSTEM = `You are a senior engineer analyzing a bug report for a software project.
13
+
14
+ Given the issue description and relevant source code, produce a fix plan.
15
+
16
+ Rules:
17
+ 1. Be conservative — only propose changes you are confident will fix the issue
18
+ 2. Use search/replace pairs for modifications so changes can be applied precisely
19
+ 3. The "search" string must be an EXACT substring of the current file contents
20
+ 4. Keep changes minimal — fix the bug, don't refactor
21
+ 5. If you cannot determine a fix from the available context, set fixable=false
22
+
23
+ Output a JSON object (no markdown fences):
24
+ {
25
+ "analysis": "your understanding of the issue and root cause",
26
+ "fixable": true/false,
27
+ "confidence": 0.0-1.0,
28
+ "files": [
29
+ {
30
+ "path": "path/to/file",
31
+ "action": "modify|create|delete",
32
+ "changes": [{"search": "exact text to find", "replace": "replacement text"}],
33
+ "content": "full content for new files only",
34
+ "explanation": "why this change fixes the issue"
35
+ }
36
+ ],
37
+ "commit_message": "fix: description (fixes #N)",
38
+ "test_suggestions": ["test cases that should verify the fix"],
39
+ "risk_notes": ["potential risks or side effects"]
40
+ }`
41
+
42
+ const REVIEW_SYSTEM = `You are a senior engineer reviewing a proposed bug fix.
43
+
44
+ Given:
45
+ - The original issue
46
+ - The proposed code changes
47
+ - The relevant source code
48
+
49
+ Evaluate whether the fix:
50
+ 1. Actually addresses the root cause
51
+ 2. Handles edge cases
52
+ 3. Could break anything else
53
+ 4. Is the minimal correct change
54
+
55
+ Output a JSON object (no markdown fences):
56
+ {
57
+ "approved": true/false,
58
+ "confidence": 0.0-1.0,
59
+ "concerns": ["any concerns about the fix"],
60
+ "verdict": "brief assessment"
61
+ }`
62
+
63
+ export async function fixIssue(
64
+ ctx: ReviewContext,
65
+ anthropic: ModelClient | null,
66
+ openai: ModelClient | null,
67
+ octokit: Octokit,
68
+ mode: FixMode,
69
+ confidenceThreshold: number
70
+ ): Promise<FixResult> {
71
+ if (!ctx.issue) return { success: false, error: "No issue context" }
72
+
73
+ const planner = anthropic || openai
74
+ const reviewer = anthropic && openai ? openai : planner
75
+ if (!planner) return { success: false, error: "No model available" }
76
+
77
+ core.info(`Analyzing issue #${ctx.issue.number}: ${ctx.issue.title}`)
78
+
79
+ const keywords = extractKeywords(ctx.issue.title, ctx.issue.body)
80
+ core.info(`Extracted ${keywords.length} keywords: ${keywords.slice(0, 5).join(", ")}`)
81
+
82
+ const codeContext = await analyzeCodebase(keywords)
83
+
84
+ if (codeContext.files.length === 0) {
85
+ return { success: false, error: "Could not find relevant code for this issue" }
86
+ }
87
+
88
+ const fixPlan = await generateFixPlan(planner, ctx, codeContext)
89
+
90
+ if (!fixPlan.fixable) {
91
+ return { success: false, fixPlan, error: `Issue not fixable: ${fixPlan.analysis}` }
92
+ }
93
+
94
+ if (fixPlan.confidence < confidenceThreshold) {
95
+ return {
96
+ success: false,
97
+ fixPlan,
98
+ error: `Confidence too low: ${(fixPlan.confidence * 100).toFixed(0)}% (threshold: ${(confidenceThreshold * 100).toFixed(0)}%)`,
99
+ }
100
+ }
101
+
102
+ if (reviewer && reviewer !== planner) {
103
+ const reviewResult = await reviewFixPlan(reviewer, ctx, fixPlan, codeContext)
104
+ if (!reviewResult.approved) {
105
+ core.warning(`Fix review rejected: ${reviewResult.verdict}`)
106
+ fixPlan.riskNotes.push(`Review concerns: ${reviewResult.concerns.join("; ")}`)
107
+
108
+ if (reviewResult.confidence < 0.5) {
109
+ return {
110
+ success: false,
111
+ fixPlan,
112
+ error: `Fix rejected by reviewer: ${reviewResult.verdict}`,
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ if (mode === "propose_only") {
119
+ core.info("Propose-only mode — returning fix plan without applying")
120
+ return { success: true, fixPlan }
121
+ }
122
+
123
+ const branch = await createFixBranch(ctx.issue.number)
124
+
125
+ try {
126
+ await applyChanges(fixPlan)
127
+ await commitAndPush(branch, fixPlan.commitMessage)
128
+
129
+ const pr = await createFixPR(octokit, ctx, fixPlan, branch)
130
+ core.info(`Fix PR created: ${pr.url}`)
131
+
132
+ if (mode === "yolo") {
133
+ core.info("Yolo mode — auto-merging fix PR")
134
+ await mergeFixPR(octokit, pr.number)
135
+ }
136
+
137
+ return {
138
+ success: true,
139
+ fixPlan,
140
+ branch,
141
+ prNumber: pr.number,
142
+ prUrl: pr.url,
143
+ }
144
+ } catch (err) {
145
+ core.warning(`Failed to apply fix: ${err}`)
146
+ try {
147
+ execSync(`git checkout ${ctx.repository.defaultBranch} 2>/dev/null || git checkout main`, { encoding: "utf-8" })
148
+ execSync(`git branch -D ${branch} 2>/dev/null`, { encoding: "utf-8" })
149
+ } catch { /* cleanup best effort */ }
150
+
151
+ return {
152
+ success: false,
153
+ fixPlan,
154
+ error: `Failed to apply changes: ${err instanceof Error ? err.message : String(err)}`,
155
+ }
156
+ }
157
+ }
158
+
159
+ async function generateFixPlan(
160
+ model: ModelClient,
161
+ ctx: ReviewContext,
162
+ codeContext: CodeContext
163
+ ): Promise<FixPlan> {
164
+ const issue = ctx.issue!
165
+ const filesContext = codeContext.files
166
+ .map((f) => `### ${f.path}\nRelevance: ${f.relevance}\n\`\`\`\n${f.content}\n\`\`\``)
167
+ .join("\n\n")
168
+
169
+ const prompt = [
170
+ `# Issue #${issue.number}: ${issue.title}`,
171
+ "",
172
+ issue.body,
173
+ "",
174
+ "## Project Structure",
175
+ codeContext.structure,
176
+ "",
177
+ "## Dependencies",
178
+ codeContext.dependencies,
179
+ "",
180
+ "## Relevant Source Files",
181
+ filesContext,
182
+ ].join("\n")
183
+
184
+ const result = await model.chat(PLAN_SYSTEM, prompt)
185
+ const parsed = parseFixPlan(result.text)
186
+
187
+ return {
188
+ analysis: parsed.analysis,
189
+ fixable: parsed.fixable,
190
+ confidence: parsed.confidence,
191
+ files: parsed.files.map((f) => ({
192
+ path: f.path,
193
+ action: f.action,
194
+ changes: f.changes,
195
+ content: f.content,
196
+ explanation: f.explanation,
197
+ })),
198
+ commitMessage: parsed.commit_message,
199
+ testSuggestions: parsed.test_suggestions,
200
+ riskNotes: parsed.risk_notes,
201
+ }
202
+ }
203
+
204
+ async function reviewFixPlan(
205
+ model: ModelClient,
206
+ ctx: ReviewContext,
207
+ plan: FixPlan,
208
+ codeContext: CodeContext
209
+ ): Promise<{ approved: boolean; confidence: number; concerns: string[]; verdict: string }> {
210
+ const issue = ctx.issue!
211
+ const changesDescription = plan.files
212
+ .map((f) => `${f.path} (${f.action}): ${f.explanation}`)
213
+ .join("\n")
214
+
215
+ const prompt = [
216
+ `# Original Issue #${issue.number}: ${issue.title}`,
217
+ issue.body,
218
+ "",
219
+ "# Proposed Fix",
220
+ `Analysis: ${plan.analysis}`,
221
+ `Confidence: ${plan.confidence}`,
222
+ "",
223
+ "## Changes",
224
+ changesDescription,
225
+ "",
226
+ "## Detailed Changes",
227
+ JSON.stringify(plan.files, null, 2),
228
+ "",
229
+ "## Relevant Code Context",
230
+ codeContext.files.slice(0, 5).map((f) => `### ${f.path}\n\`\`\`\n${f.content.substring(0, 3000)}\n\`\`\``).join("\n\n"),
231
+ ].join("\n")
232
+
233
+ try {
234
+ const result = await model.chat(REVIEW_SYSTEM, prompt)
235
+ const parsed = parseFixReview(result.text)
236
+ return parsed
237
+ } catch {
238
+ return { approved: true, confidence: 0.5, concerns: ["Review parsing failed"], verdict: "Proceeding with caution" }
239
+ }
240
+ }
241
+
242
+ async function createFixBranch(issueNumber: number): Promise<string> {
243
+ const branch = `fix/issue-${issueNumber}`
244
+ execSync(`git checkout -b ${branch}`, { encoding: "utf-8" })
245
+ return branch
246
+ }
247
+
248
+ async function applyChanges(plan: FixPlan): Promise<void> {
249
+ for (const file of plan.files) {
250
+ if (file.action === "delete") {
251
+ if (fs.existsSync(file.path)) {
252
+ fs.unlinkSync(file.path)
253
+ core.info(`Deleted: ${file.path}`)
254
+ }
255
+ continue
256
+ }
257
+
258
+ if (file.action === "create") {
259
+ const dir = file.path.split("/").slice(0, -1).join("/")
260
+ if (dir) fs.mkdirSync(dir, { recursive: true })
261
+ fs.writeFileSync(file.path, file.content || "")
262
+ core.info(`Created: ${file.path}`)
263
+ continue
264
+ }
265
+
266
+ if (file.action === "modify" && file.changes) {
267
+ if (!fs.existsSync(file.path)) {
268
+ throw new Error(`File not found: ${file.path}`)
269
+ }
270
+
271
+ let content = fs.readFileSync(file.path, "utf-8")
272
+
273
+ for (const change of file.changes) {
274
+ if (!content.includes(change.search)) {
275
+ throw new Error(`Search text not found in ${file.path}: "${change.search.substring(0, 60)}..."`)
276
+ }
277
+ content = content.replace(change.search, change.replace)
278
+ }
279
+
280
+ fs.writeFileSync(file.path, content)
281
+ core.info(`Modified: ${file.path} (${file.changes.length} change(s))`)
282
+ }
283
+ }
284
+ }
285
+
286
+ async function commitAndPush(branch: string, message: string): Promise<void> {
287
+ execSync('git config user.name "sentinel[bot]"', { encoding: "utf-8" })
288
+ execSync('git config user.email "sentinel[bot]@users.noreply.github.com"', { encoding: "utf-8" })
289
+ execSync("git add -A", { encoding: "utf-8" })
290
+ execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { encoding: "utf-8" })
291
+ execSync(`git push origin ${branch}`, { encoding: "utf-8" })
292
+ }
293
+
294
+ async function createFixPR(
295
+ octokit: Octokit,
296
+ ctx: ReviewContext,
297
+ plan: FixPlan,
298
+ branch: string
299
+ ): Promise<{ number: number; url: string }> {
300
+ const { owner, name: repo } = ctx.repository
301
+ const issue = ctx.issue!
302
+
303
+ const body = [
304
+ `Fixes #${issue.number}`,
305
+ "",
306
+ "## Analysis",
307
+ plan.analysis,
308
+ "",
309
+ "## Changes",
310
+ ...plan.files.map((f) => `- **${f.path}** (${f.action}): ${f.explanation}`),
311
+ "",
312
+ plan.riskNotes.length > 0
313
+ ? `## Risk Notes\n${plan.riskNotes.map((n) => `- ⚠️ ${n}`).join("\n")}`
314
+ : "",
315
+ "",
316
+ plan.testSuggestions.length > 0
317
+ ? `## Suggested Tests\n${plan.testSuggestions.map((t) => `- [ ] ${t}`).join("\n")}`
318
+ : "",
319
+ "",
320
+ `---`,
321
+ `*Generated by Sentinel · Confidence: ${(plan.confidence * 100).toFixed(0)}%*`,
322
+ ]
323
+ .filter(Boolean)
324
+ .join("\n")
325
+
326
+ const { data: pr } = await octokit.rest.pulls.create({
327
+ owner,
328
+ repo,
329
+ title: plan.commitMessage,
330
+ body,
331
+ head: branch,
332
+ base: ctx.repository.defaultBranch,
333
+ draft: ctx.repoPolicies.fix.createDraftPr,
334
+ })
335
+
336
+ return { number: pr.number, url: pr.html_url }
337
+ }
338
+
339
+ async function mergeFixPR(octokit: Octokit, prNumber: number): Promise<void> {
340
+ const { owner, repo } = github.context.repo
341
+
342
+ try {
343
+ await octokit.rest.pulls.merge({
344
+ owner,
345
+ repo,
346
+ pull_number: prNumber,
347
+ merge_method: "squash",
348
+ })
349
+ core.info(`Auto-merged PR #${prNumber} (yolo mode)`)
350
+ } catch (err) {
351
+ core.warning(`Auto-merge failed for PR #${prNumber}: ${err}`)
352
+ }
353
+ }
@@ -0,0 +1,263 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import { routeEvent } from "./router"
4
+ import { buildPRContext, buildIssueContext } from "./context"
5
+ import { loadPolicies, evaluateTrust } from "./policy"
6
+ import { orchestrateReview } from "./orchestrator"
7
+ import { reportReview, reportIssueTriage, reportFailure, reportFixResult } from "./reporter"
8
+ import { fixIssue } from "./fixer"
9
+ import { handleResponse } from "./responder"
10
+ import { AnthropicClient } from "./models/anthropic"
11
+ import { OpenAIClient } from "./models/openai"
12
+ import { OpenRouterClient } from "./models/openrouter"
13
+ import { readPrContact, readCommitContact, notifySubwayAgent } from "./subway"
14
+ import type { ModelClient } from "./models/types"
15
+
16
+ async function run(): Promise<void> {
17
+ const start = Date.now()
18
+
19
+ try {
20
+ const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN || ""
21
+ const anthropicKey = core.getInput("anthropic_api_key")
22
+ const openaiKey = core.getInput("openai_api_key")
23
+ const openrouterKey = core.getInput("openrouter_api_key")
24
+ const configPath = core.getInput("config_path") || ".github/sentinel.yml"
25
+ const modeOverride = core.getInput("mode") || undefined
26
+ const debug = core.getInput("debug") === "true"
27
+
28
+ if (debug) core.info(`Event: ${github.context.eventName} / ${github.context.payload.action}`)
29
+
30
+ if (!githubToken) {
31
+ core.setFailed("github_token is required")
32
+ return
33
+ }
34
+
35
+ const octokit = github.getOctokit(githubToken)
36
+ const policies = await loadPolicies(octokit, configPath, modeOverride)
37
+ const routed = routeEvent(policies)
38
+
39
+ if (debug) core.info(`Routed to: ${routed.actionType}`)
40
+
41
+ if (routed.actionType === "noop") {
42
+ core.info("No action required for this event")
43
+ return
44
+ }
45
+
46
+ let anthropic: ModelClient | null = null
47
+ let openai: ModelClient | null = null
48
+
49
+ if (anthropicKey && policies.models.anthropic.enabled) {
50
+ anthropic = new AnthropicClient(anthropicKey, policies.models.anthropic.model)
51
+ } else if (openrouterKey && policies.models.anthropic.enabled) {
52
+ const model = core.getInput("openrouter_anthropic_model") || "anthropic/claude-sonnet-4-20250514"
53
+ anthropic = new OpenRouterClient(openrouterKey, model, "anthropic")
54
+ core.info(`Anthropic slot: using OpenRouter fallback (${model})`)
55
+ } else {
56
+ core.warning("Anthropic disabled or no API key (no OpenRouter fallback)")
57
+ }
58
+
59
+ if (openaiKey && policies.models.openai.enabled) {
60
+ openai = new OpenAIClient(openaiKey, policies.models.openai.model)
61
+ } else if (openrouterKey && policies.models.openai.enabled) {
62
+ const model = core.getInput("openrouter_openai_model") || "openai/gpt-4o"
63
+ openai = new OpenRouterClient(openrouterKey, model, "openai")
64
+ core.info(`OpenAI slot: using OpenRouter fallback (${model})`)
65
+ } else {
66
+ core.warning("OpenAI disabled or no API key (no OpenRouter fallback)")
67
+ }
68
+
69
+ if (!anthropic && !openai) {
70
+ core.setFailed("At least one model must be configured (anthropic_api_key, openai_api_key, or openrouter_api_key)")
71
+ return
72
+ }
73
+
74
+ switch (routed.actionType) {
75
+ case "pr_review": {
76
+ await handlePRReview(octokit, routed.context, anthropic, openai, debug, policies.review.summaryOnClean)
77
+ break
78
+ }
79
+ case "issue_fix": {
80
+ await handleIssueFix(octokit, routed.context, anthropic, openai, debug)
81
+ break
82
+ }
83
+ case "issue_triage": {
84
+ await handleIssueTriage(octokit, routed.context)
85
+ break
86
+ }
87
+ case "respond": {
88
+ if (routed.responseContext) {
89
+ const model = anthropic || openai!
90
+ await handleResponse(routed.context, routed.responseContext, model, octokit)
91
+ }
92
+ break
93
+ }
94
+ case "pr_fix": {
95
+ core.info("PR fix mode not yet implemented (Phase 2)")
96
+ break
97
+ }
98
+ case "slash_command": {
99
+ const cmd = routed.slashCommand
100
+ if (cmd?.command === "fix") {
101
+ if (cmd.isPR) {
102
+ core.info("PR fix via slash command not yet implemented")
103
+ } else {
104
+ await handleIssueFix(octokit, routed.context, anthropic, openai, debug)
105
+ }
106
+ } else if (cmd?.command === "review" && cmd.isPR) {
107
+ await handlePRReview(octokit, routed.context, anthropic, openai, debug)
108
+ } else {
109
+ core.info(`Slash command /bot ${cmd?.command} — not yet implemented`)
110
+ }
111
+ break
112
+ }
113
+ }
114
+
115
+ core.info(`Sentinel completed in ${((Date.now() - start) / 1000).toFixed(1)}s`)
116
+ } catch (err) {
117
+ core.setFailed(`Sentinel failed: ${err instanceof Error ? err.message : String(err)}`)
118
+
119
+ try {
120
+ const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN || ""
121
+ if (githubToken) {
122
+ const octokit = github.getOctokit(githubToken)
123
+ const prNumber = github.context.payload.pull_request?.number
124
+ || github.context.payload.issue?.number
125
+ if (prNumber) {
126
+ await reportFailure(octokit, prNumber, err instanceof Error ? err.message : String(err))
127
+ }
128
+ }
129
+ } catch {
130
+ core.debug("Could not post failure comment")
131
+ }
132
+ }
133
+ }
134
+
135
+ async function handlePRReview(
136
+ octokit: ReturnType<typeof github.getOctokit>,
137
+ ctx: import("./types").ReviewContext,
138
+ anthropic: ModelClient | null,
139
+ openai: ModelClient | null,
140
+ debug: boolean,
141
+ summaryOnClean = false
142
+ ): Promise<void> {
143
+ if (!ctx.pullRequest) {
144
+ core.warning("No PR context available")
145
+ return
146
+ }
147
+
148
+ ctx = await buildPRContext(ctx, octokit)
149
+
150
+ if (ctx.pullRequest!.changedFiles.length === 0) {
151
+ core.info("No changed files to review")
152
+ return
153
+ }
154
+
155
+ if (debug) {
156
+ core.info(`Reviewing PR #${ctx.pullRequest!.number}: ${ctx.pullRequest!.changedFiles.length} files`)
157
+ }
158
+
159
+ const decision = await orchestrateReview(ctx, anthropic, openai)
160
+
161
+ if (debug) {
162
+ core.info(`Decision: ${decision.action}, ${decision.findings.length} findings, ${decision.durationMs}ms`)
163
+ }
164
+
165
+ await reportReview(octokit, decision, ctx.pullRequest!.number, summaryOnClean)
166
+
167
+ if (core.getInput("subway_notify") !== "false") {
168
+ try {
169
+ // Commit trailer takes priority — always fresh, no file needed.
170
+ // Falls back to .subway/pr-contact if no trailer found.
171
+ const headSha = github.context.payload.pull_request?.head?.sha as string | undefined
172
+ const contact = readCommitContact(headSha) ?? readPrContact()
173
+ const bridgeUrl = core.getInput("subway_bridge_url") || "https://relay.subway.dev"
174
+ const { owner, name } = ctx.repository
175
+ const prNumber = ctx.pullRequest!.number
176
+
177
+ // Use event payload for PR state — avoids an extra API call.
178
+ // github.context.payload.pull_request is populated for pull_request events;
179
+ // fall back to API only for non-PR triggers (e.g. issue_comment on a PR).
180
+ const prPayload = github.context.payload.pull_request
181
+ let prState: "open" | "closed" | "merged"
182
+ if (prPayload) {
183
+ prState = prPayload.merged ? "merged" : prPayload.state === "open" ? "open" : "closed"
184
+ } else {
185
+ const { data: prData } = await octokit.rest.pulls.get({ owner, repo: name, pull_number: prNumber })
186
+ prState = prData.merged ? "merged" : prData.state === "open" ? "open" : "closed"
187
+ }
188
+
189
+ if (prState !== "open") {
190
+ core.info(`Subway: PR #${prNumber} is ${prState} — skipping notification`)
191
+ } else {
192
+ await notifySubwayAgent(contact, decision, {
193
+ prNumber,
194
+ prUrl: `https://github.com/${owner}/${name}/pull/${prNumber}`,
195
+ repo: `${owner}/${name}`,
196
+ headSha: github.context.payload.pull_request?.head?.sha ?? process.env.GITHUB_SHA ?? "unknown",
197
+ prState,
198
+ runUrl: `${process.env.GITHUB_SERVER_URL ?? "https://github.com"}/${process.env.GITHUB_REPOSITORY ?? `${owner}/${name}`}/actions/runs/${process.env.GITHUB_RUN_ID ?? ""}`,
199
+ }, bridgeUrl)
200
+ }
201
+ } catch (err) {
202
+ core.info(`Subway notification failed non-fatally: ${(err as Error).message}`)
203
+ }
204
+ }
205
+ }
206
+
207
+ async function handleIssueFix(
208
+ octokit: ReturnType<typeof github.getOctokit>,
209
+ ctx: import("./types").ReviewContext,
210
+ anthropic: ModelClient | null,
211
+ openai: ModelClient | null,
212
+ debug: boolean
213
+ ): Promise<void> {
214
+ if (!ctx.issue) {
215
+ core.warning("No issue context available")
216
+ return
217
+ }
218
+
219
+ ctx = await buildIssueContext(ctx, octokit)
220
+
221
+ if (debug) {
222
+ core.info(`Fixing issue #${ctx.issue!.number}: ${ctx.issue!.title}`)
223
+ }
224
+
225
+ const { mode, confidenceThreshold } = ctx.repoPolicies.fix
226
+
227
+ const trust = evaluateTrust({
228
+ actor: ctx.event.actor,
229
+ isFork: ctx.event.isFork,
230
+ policies: ctx.repoPolicies,
231
+ })
232
+
233
+ const effectiveMode = trust.canMutate ? mode : "propose_only" as const
234
+ if (effectiveMode !== mode) {
235
+ core.info(`Mutation blocked: ${trust.reason}. Mode downgraded to propose_only.`)
236
+ }
237
+
238
+ const result = await fixIssue(ctx, anthropic, openai, octokit, effectiveMode, confidenceThreshold)
239
+
240
+ await reportFixResult(octokit, ctx.issue!.number, result, mode)
241
+
242
+ if (result.success) {
243
+ core.info(`Fix ${mode === "propose_only" ? "proposed" : "applied"} for issue #${ctx.issue!.number}`)
244
+ } else {
245
+ core.warning(`Fix failed for issue #${ctx.issue!.number}: ${result.error}`)
246
+ }
247
+ }
248
+
249
+ async function handleIssueTriage(
250
+ octokit: ReturnType<typeof github.getOctokit>,
251
+ ctx: import("./types").ReviewContext
252
+ ): Promise<void> {
253
+ if (!ctx.issue) {
254
+ core.warning("No issue context available")
255
+ return
256
+ }
257
+
258
+ ctx = await buildIssueContext(ctx, octokit)
259
+
260
+ core.info(`Issue #${ctx.issue!.number} triage: routing to fix flow`)
261
+ }
262
+
263
+ run()