@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,156 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import type { ModelClient } from "./models/types"
4
+ import type { ReviewContext, ResponseContext } from "./types"
5
+
6
+ type Octokit = ReturnType<typeof github.getOctokit>
7
+
8
+ const RESPOND_SYSTEM = `You are Sentinel, an AI code review bot responding to a developer's question or comment about a code review finding.
9
+
10
+ Guidelines:
11
+ 1. If they point out your finding was wrong, acknowledge it clearly
12
+ 2. If they ask for clarification, provide specific code-level detail
13
+ 3. If they ask you to fix something, describe the fix precisely
14
+ 4. If they disagree, engage constructively with their reasoning
15
+ 5. Be concise and direct — developers don't want fluff
16
+ 6. Reference specific files and line numbers when relevant
17
+
18
+ Do NOT output JSON. Respond in plain markdown as a conversation reply.`
19
+
20
+ export async function handleResponse(
21
+ ctx: ReviewContext,
22
+ responseContext: ResponseContext,
23
+ model: ModelClient,
24
+ octokit: Octokit
25
+ ): Promise<void> {
26
+ const { owner, repo } = github.context.repo
27
+ const parentBody = await resolveParentComment(octokit, responseContext)
28
+
29
+ const contextParts: string[] = []
30
+
31
+ if (parentBody) {
32
+ contextParts.push(`## Original Sentinel Comment\n${parentBody}`)
33
+ }
34
+
35
+ if (ctx.pullRequest) {
36
+ contextParts.push(`## PR Context\nPR #${ctx.pullRequest.number}: ${ctx.pullRequest.title}\n${ctx.pullRequest.body || ""}`)
37
+ }
38
+
39
+ if (ctx.issue) {
40
+ contextParts.push(`## Issue Context\nIssue #${ctx.issue.number}: ${ctx.issue.title}\n${ctx.issue.body || ""}`)
41
+ }
42
+
43
+ const prompt = [
44
+ ...contextParts,
45
+ "",
46
+ "## Developer's Comment",
47
+ responseContext.replyBody,
48
+ ].join("\n\n")
49
+
50
+ core.info("Generating response to developer comment")
51
+
52
+ const result = await model.chat(RESPOND_SYSTEM, prompt)
53
+ const responseBody = formatResponse(result.text)
54
+
55
+ if (responseContext.isPRReviewComment) {
56
+ await replyToReviewComment(octokit, responseContext.commentId, responseBody)
57
+ } else {
58
+ const issueNumber = ctx.pullRequest?.number || ctx.issue?.number
59
+ if (issueNumber) {
60
+ await octokit.rest.issues.createComment({
61
+ owner,
62
+ repo,
63
+ issue_number: issueNumber,
64
+ body: responseBody,
65
+ })
66
+ }
67
+ }
68
+
69
+ core.info("Response posted")
70
+ }
71
+
72
+ async function resolveParentComment(
73
+ octokit: Octokit,
74
+ responseContext: ResponseContext
75
+ ): Promise<string> {
76
+ if (responseContext.parentCommentBody) {
77
+ return responseContext.parentCommentBody
78
+ }
79
+
80
+ const { owner, repo } = github.context.repo
81
+
82
+ if (responseContext.isPRReviewComment) {
83
+ const inReplyToId = github.context.payload.comment?.in_reply_to_id
84
+ if (inReplyToId) {
85
+ try {
86
+ const { data: parent } = await octokit.rest.pulls.getReviewComment({
87
+ owner,
88
+ repo,
89
+ comment_id: inReplyToId,
90
+ })
91
+ return parent.body || ""
92
+ } catch {
93
+ core.debug(`Could not fetch parent review comment ${inReplyToId}`)
94
+ }
95
+ }
96
+ } else {
97
+ const issueNumber = github.context.payload.issue?.number
98
+ if (issueNumber) {
99
+ try {
100
+ const { data: comments } = await octokit.rest.issues.listComments({
101
+ owner,
102
+ repo,
103
+ issue_number: issueNumber,
104
+ per_page: 10,
105
+ })
106
+
107
+ for (let i = comments.length - 1; i >= 0; i--) {
108
+ if (comments[i].body?.includes("Sentinel") && comments[i].id !== responseContext.commentId) {
109
+ return comments[i].body || ""
110
+ }
111
+ }
112
+ } catch {
113
+ core.debug("Could not fetch previous comments")
114
+ }
115
+ }
116
+ }
117
+
118
+ return ""
119
+ }
120
+
121
+ async function replyToReviewComment(
122
+ octokit: Octokit,
123
+ commentId: number,
124
+ body: string
125
+ ): Promise<void> {
126
+ const { owner, repo } = github.context.repo
127
+ const prNumber = github.context.payload.pull_request?.number
128
+
129
+ if (!prNumber) {
130
+ core.warning("No PR number for review comment reply")
131
+ return
132
+ }
133
+
134
+ try {
135
+ await octokit.rest.pulls.createReplyForReviewComment({
136
+ owner,
137
+ repo,
138
+ pull_number: prNumber,
139
+ comment_id: commentId,
140
+ body,
141
+ })
142
+ } catch (err) {
143
+ core.warning(`Could not reply to review comment, posting as issue comment: ${err}`)
144
+ await octokit.rest.issues.createComment({
145
+ owner,
146
+ repo,
147
+ issue_number: prNumber,
148
+ body,
149
+ })
150
+ }
151
+ }
152
+
153
+ function formatResponse(text: string): string {
154
+ const cleaned = text.trim()
155
+ return `${cleaned}\n\n---\n*Sentinel*`
156
+ }
@@ -0,0 +1,308 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import type { ActionType, EventType, SlashCommand, RoutedEvent, ReviewContext, RepoPolicies, ResponseContext } from "./types"
4
+
5
+ const SLASH_COMMANDS = ["review", "fix", "triage", "plan", "explain", "retry", "ignore", "security-review", "tests"]
6
+ const BOT_MARKER = "Sentinel"
7
+ const TRUSTED_ASSOCIATIONS = ["MEMBER", "OWNER", "COLLABORATOR"]
8
+
9
+ export function routeEvent(policies: RepoPolicies): RoutedEvent {
10
+ const { context } = github
11
+ const eventName = context.eventName
12
+ const action = context.payload.action || ""
13
+ const actor = context.actor
14
+
15
+ const pr = context.payload.pull_request
16
+ const isFork = pr ? pr.head?.repo?.full_name !== context.payload.repository?.full_name : false
17
+ const authorAssociation = (context.payload.comment?.author_association as string) || undefined
18
+
19
+ core.info(`Routing event: ${eventName} / ${action} from ${actor} (assoc=${authorAssociation || "N/A"}, fork=${isFork})`)
20
+
21
+ const baseContext: ReviewContext = {
22
+ repository: {
23
+ owner: context.repo.owner,
24
+ name: context.repo.repo,
25
+ defaultBranch: context.payload.repository?.default_branch || "main",
26
+ },
27
+ event: {
28
+ type: mapEventType(eventName),
29
+ action,
30
+ actor,
31
+ trustedActor: TRUSTED_ASSOCIATIONS.includes(authorAssociation || ""),
32
+ isFork,
33
+ authorAssociation,
34
+ },
35
+ repoPolicies: policies,
36
+ }
37
+
38
+ if (eventName === "pull_request") {
39
+ return routePullRequest(baseContext, action, policies)
40
+ }
41
+
42
+ if (eventName === "issues") {
43
+ return routeIssue(baseContext, action, policies)
44
+ }
45
+
46
+ if (eventName === "issue_comment") {
47
+ return routeComment(baseContext, policies)
48
+ }
49
+
50
+ if (eventName === "pull_request_review_comment") {
51
+ return routeReviewComment(baseContext, policies)
52
+ }
53
+
54
+ if (eventName === "workflow_dispatch") {
55
+ return { actionType: "pr_review", context: baseContext }
56
+ }
57
+
58
+ core.info(`Unhandled event: ${eventName}`)
59
+ return { actionType: "noop", context: baseContext }
60
+ }
61
+
62
+ // ── Pull Request Events ──
63
+
64
+ function routePullRequest(ctx: ReviewContext, action: string, policies: RepoPolicies): RoutedEvent {
65
+ const pr = github.context.payload.pull_request
66
+ if (!pr) return { actionType: "noop", context: ctx }
67
+
68
+ const labels: string[] = (pr.labels || []).map((l: { name: string }) => l.name)
69
+
70
+ const labelGateEnabled = policies.trigger.requireLabel !== ""
71
+
72
+ if (action === "labeled") {
73
+ if (labelGateEnabled) {
74
+ const addedLabel = github.context.payload.label?.name || ""
75
+ if (addedLabel.toLowerCase() !== policies.trigger.requireLabel.toLowerCase()) {
76
+ return { actionType: "noop", context: ctx }
77
+ }
78
+ }
79
+ } else if (["opened", "synchronize", "reopened"].includes(action)) {
80
+ if (labelGateEnabled && !hasLabel(labels, policies.trigger.requireLabel)) {
81
+ core.info(`PR #${pr.number} missing "${policies.trigger.requireLabel}" label — skipping`)
82
+ return { actionType: "noop", context: ctx }
83
+ }
84
+ } else {
85
+ return { actionType: "noop", context: ctx }
86
+ }
87
+
88
+ ctx.pullRequest = {
89
+ number: pr.number,
90
+ title: pr.title || "",
91
+ body: pr.body || "",
92
+ baseRef: pr.base?.ref || "main",
93
+ headRef: pr.head?.ref || "",
94
+ labels,
95
+ changedFiles: [],
96
+ }
97
+
98
+ return { actionType: "pr_review", context: ctx }
99
+ }
100
+
101
+ // ── Issue Events ──
102
+
103
+ function routeIssue(ctx: ReviewContext, action: string, policies: RepoPolicies): RoutedEvent {
104
+ const issue = github.context.payload.issue
105
+ if (!issue) return { actionType: "noop", context: ctx }
106
+
107
+ const labels: string[] = (issue.labels || []).map((l: { name: string }) => l.name)
108
+
109
+ const labelGateEnabled = policies.trigger.requireLabel !== ""
110
+
111
+ if (action === "labeled") {
112
+ if (labelGateEnabled) {
113
+ const addedLabel = github.context.payload.label?.name || ""
114
+ if (addedLabel.toLowerCase() !== policies.trigger.requireLabel.toLowerCase()) {
115
+ return { actionType: "noop", context: ctx }
116
+ }
117
+ }
118
+ } else if (action === "opened") {
119
+ if (labelGateEnabled && !hasLabel(labels, policies.trigger.requireLabel)) {
120
+ core.info(`Issue #${issue.number} missing "${policies.trigger.requireLabel}" label — skipping`)
121
+ return { actionType: "noop", context: ctx }
122
+ }
123
+ } else {
124
+ return { actionType: "noop", context: ctx }
125
+ }
126
+
127
+ ctx.issue = {
128
+ number: issue.number,
129
+ title: issue.title || "",
130
+ body: issue.body || "",
131
+ labels,
132
+ }
133
+
134
+ return { actionType: "issue_fix", context: ctx }
135
+ }
136
+
137
+ // ── Issue / PR Comment Events ──
138
+
139
+ function routeComment(ctx: ReviewContext, policies: RepoPolicies): RoutedEvent {
140
+ const comment = github.context.payload.comment
141
+ const issue = github.context.payload.issue
142
+ if (!comment || !issue) return { actionType: "noop", context: ctx }
143
+
144
+ if (!ctx.event.trustedActor) {
145
+ core.info(`Ignoring comment from untrusted actor ${ctx.event.actor} (association: ${ctx.event.authorAssociation || "NONE"})`)
146
+ return { actionType: "noop", context: ctx }
147
+ }
148
+
149
+ const body = (comment.body || "").trim()
150
+ const isPR = !!issue.pull_request
151
+ const labels: string[] = (issue.labels || []).map((l: { name: string }) => l.name)
152
+ const hasAgentLabel = hasLabel(labels, policies.trigger.requireLabel)
153
+ const botName = policies.trigger.botName
154
+
155
+ const slashCommand = parseSlashCommand(body, ctx.event.actor, issue.number, isPR)
156
+ if (slashCommand) {
157
+ const actionType = resolveCommandAction(slashCommand, isPR)
158
+ attachIssueOrPR(ctx, issue, isPR)
159
+ return { actionType, context: ctx, slashCommand }
160
+ }
161
+
162
+ const mentioned = isMentioned(body, botName)
163
+ if (mentioned) {
164
+ attachIssueOrPR(ctx, issue, isPR)
165
+ const actionType = isPR ? "pr_review" : "issue_fix"
166
+ core.info(`@${botName} mentioned in comment on ${isPR ? "PR" : "issue"} #${issue.number}`)
167
+ return { actionType, context: ctx }
168
+ }
169
+
170
+ if (policies.trigger.respondToReplies && hasAgentLabel) {
171
+ const isReplyToBot = isBotComment(comment.body, body)
172
+ if (isReplyToBot) {
173
+ attachIssueOrPR(ctx, issue, isPR)
174
+ const responseContext: ResponseContext = {
175
+ parentCommentBody: "",
176
+ replyBody: body,
177
+ commentId: comment.id,
178
+ isPRReviewComment: false,
179
+ }
180
+ return { actionType: "respond", context: ctx, responseContext }
181
+ }
182
+ }
183
+
184
+ return { actionType: "noop", context: ctx }
185
+ }
186
+
187
+ // ── PR Review Comment Events (reply detection) ──
188
+
189
+ function routeReviewComment(ctx: ReviewContext, policies: RepoPolicies): RoutedEvent {
190
+ const comment = github.context.payload.comment
191
+ const pr = github.context.payload.pull_request
192
+ if (!comment || !pr) return { actionType: "noop", context: ctx }
193
+
194
+ if (!ctx.event.trustedActor) {
195
+ core.info(`Ignoring review comment from untrusted actor ${ctx.event.actor} (association: ${ctx.event.authorAssociation || "NONE"})`)
196
+ return { actionType: "noop", context: ctx }
197
+ }
198
+
199
+ const body = (comment.body || "").trim()
200
+ const botName = policies.trigger.botName
201
+ const inReplyToId = comment.in_reply_to_id
202
+
203
+ ctx.pullRequest = {
204
+ number: pr.number,
205
+ title: pr.title || "",
206
+ body: pr.body || "",
207
+ baseRef: pr.base?.ref || "main",
208
+ headRef: pr.head?.ref || "",
209
+ labels: (pr.labels || []).map((l: { name: string }) => l.name),
210
+ changedFiles: [],
211
+ }
212
+
213
+ if (isMentioned(body, botName)) {
214
+ core.info(`@${botName} mentioned in review comment on PR #${pr.number}`)
215
+ return { actionType: "pr_review", context: ctx }
216
+ }
217
+
218
+ if (policies.trigger.respondToReplies && inReplyToId) {
219
+ const responseContext: ResponseContext = {
220
+ parentCommentBody: "",
221
+ replyBody: body,
222
+ commentId: comment.id,
223
+ isPRReviewComment: true,
224
+ }
225
+ return { actionType: "respond", context: ctx, responseContext }
226
+ }
227
+
228
+ return { actionType: "noop", context: ctx }
229
+ }
230
+
231
+ // ── Helpers ──
232
+
233
+ function hasLabel(labels: string[], target: string): boolean {
234
+ return labels.some((l) => l.toLowerCase() === target.toLowerCase())
235
+ }
236
+
237
+ function isMentioned(body: string, botName: string): boolean {
238
+ return body.toLowerCase().includes(`@${botName.toLowerCase()}`)
239
+ }
240
+
241
+ function isBotComment(_commentBody: string, _body: string): boolean {
242
+ return false
243
+ }
244
+
245
+ function attachIssueOrPR(
246
+ ctx: ReviewContext,
247
+ issue: { number: number; title?: string; body?: string; labels?: Array<{ name: string }>; pull_request?: unknown },
248
+ isPR: boolean
249
+ ): void {
250
+ if (isPR) {
251
+ ctx.pullRequest = {
252
+ number: issue.number,
253
+ title: issue.title || "",
254
+ body: issue.body || "",
255
+ baseRef: "",
256
+ headRef: "",
257
+ labels: (issue.labels || []).map((l: { name: string }) => l.name),
258
+ changedFiles: [],
259
+ }
260
+ } else {
261
+ ctx.issue = {
262
+ number: issue.number,
263
+ title: issue.title || "",
264
+ body: issue.body || "",
265
+ labels: (issue.labels || []).map((l: { name: string }) => l.name),
266
+ }
267
+ }
268
+ }
269
+
270
+ function parseSlashCommand(body: string, actor: string, issueNumber: number, isPR: boolean): SlashCommand | undefined {
271
+ const match = body.match(/^\/bot\s+(\S+)(.*)$/m)
272
+ if (!match) return undefined
273
+
274
+ const command = match[1].toLowerCase()
275
+ if (!SLASH_COMMANDS.includes(command)) return undefined
276
+
277
+ const args = match[2].trim().split(/\s+/).filter(Boolean)
278
+ return { command, args, actor, issueNumber, isPR }
279
+ }
280
+
281
+ function resolveCommandAction(cmd: SlashCommand, isPR: boolean): ActionType {
282
+ switch (cmd.command) {
283
+ case "review":
284
+ return isPR ? "pr_review" : "noop"
285
+ case "fix":
286
+ return isPR ? "pr_fix" : "issue_fix"
287
+ case "triage":
288
+ return "issue_triage"
289
+ case "plan":
290
+ case "explain":
291
+ return isPR ? "pr_review" : "issue_triage"
292
+ case "security-review":
293
+ case "tests":
294
+ return isPR ? "pr_review" : "noop"
295
+ case "ignore":
296
+ case "retry":
297
+ default:
298
+ return "noop"
299
+ }
300
+ }
301
+
302
+ function mapEventType(eventName: string): EventType {
303
+ if (eventName === "pull_request") return "pull_request"
304
+ if (eventName === "issues") return "issue"
305
+ if (eventName === "issue_comment") return "issue_comment"
306
+ if (eventName === "pull_request_review_comment") return "pull_request_review_comment"
307
+ return "issue_comment"
308
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from "zod"
2
+
3
+ export const SentinelConfigSchema = z.object({
4
+ mode: z
5
+ .enum([
6
+ "review",
7
+ "review_and_suggest",
8
+ "review_and_patch",
9
+ "issue_triage",
10
+ "issue_fix",
11
+ "manual_only",
12
+ ])
13
+ .default("review"),
14
+
15
+ models: z
16
+ .object({
17
+ anthropic: z
18
+ .object({
19
+ enabled: z.boolean().default(true),
20
+ model: z.string().default("claude-sonnet-4-20250514"),
21
+ })
22
+ .default({}),
23
+ openai: z
24
+ .object({
25
+ enabled: z.boolean().default(true),
26
+ model: z.string().default("gpt-4o"),
27
+ })
28
+ .default({}),
29
+ })
30
+ .default({}),
31
+
32
+ trigger: z
33
+ .object({
34
+ require_label: z.string().default("agent"), // set to "" to run on every PR/issue
35
+ respond_to_mentions: z.boolean().default(true),
36
+ respond_to_replies: z.boolean().default(true),
37
+ bot_name: z.string().default("sentinel"),
38
+ })
39
+ .default({}),
40
+
41
+ review: z
42
+ .object({
43
+ max_files: z.number().default(50),
44
+ max_patch_chars: z.number().default(200_000),
45
+ comment_style: z.enum(["concise", "comprehensive"]).default("comprehensive"),
46
+ inline_comments: z.boolean().default(true),
47
+ severity_threshold: z
48
+ .enum(["low", "medium", "high", "critical"])
49
+ .default("medium"),
50
+ summary_on_clean: z.boolean().default(false),
51
+ })
52
+ .default({}),
53
+
54
+ fix: z
55
+ .object({
56
+ mode: z.enum(["propose_only", "propose_and_pr", "yolo"]).default("propose_and_pr"),
57
+ confidence_threshold: z.number().min(0).max(1).default(0.7),
58
+ max_retry_count: z.number().default(2),
59
+ create_draft_pr: z.boolean().default(true),
60
+ })
61
+ .default({}),
62
+
63
+ security: z
64
+ .object({
65
+ restricted_paths: z
66
+ .array(z.string())
67
+ .default([
68
+ ".github/workflows/**",
69
+ "infra/**",
70
+ "auth/**",
71
+ "payments/**",
72
+ ]),
73
+ block_fork_mutation: z.boolean().default(true),
74
+ })
75
+ .default({}),
76
+
77
+ validation: z
78
+ .object({
79
+ commands: z.array(z.string()).default([]),
80
+ })
81
+ .default({}),
82
+ })
83
+
84
+ export type SentinelConfig = z.infer<typeof SentinelConfigSchema>
@@ -0,0 +1,44 @@
1
+ import { z } from "zod"
2
+
3
+ export const FileChangeSchema = z.object({
4
+ path: z.string(),
5
+ action: z.enum(["modify", "create", "delete"]),
6
+ changes: z
7
+ .array(z.object({ search: z.string(), replace: z.string() }))
8
+ .optional(),
9
+ content: z.string().optional(),
10
+ explanation: z.string(),
11
+ })
12
+
13
+ export const FixPlanSchema = z.object({
14
+ analysis: z.string(),
15
+ fixable: z.boolean(),
16
+ confidence: z.number().min(0).max(1),
17
+ files: z.array(FileChangeSchema),
18
+ commit_message: z.string(),
19
+ test_suggestions: z.array(z.string()),
20
+ risk_notes: z.array(z.string()),
21
+ })
22
+
23
+ export const FixReviewSchema = z.object({
24
+ approved: z.boolean(),
25
+ confidence: z.number().min(0).max(1),
26
+ concerns: z.array(z.string()),
27
+ verdict: z.string(),
28
+ })
29
+
30
+ export function parseFixPlan(raw: string): z.infer<typeof FixPlanSchema> {
31
+ const json = extractJson(raw)
32
+ return FixPlanSchema.parse(json)
33
+ }
34
+
35
+ export function parseFixReview(raw: string): z.infer<typeof FixReviewSchema> {
36
+ const json = extractJson(raw)
37
+ return FixReviewSchema.parse(json)
38
+ }
39
+
40
+ function extractJson(text: string): unknown {
41
+ const match = text.match(/\{[\s\S]*\}/)
42
+ if (!match) throw new Error("No JSON object found in model response")
43
+ return JSON.parse(match[0])
44
+ }
@@ -0,0 +1,73 @@
1
+ import { z } from "zod"
2
+
3
+ export const FindingSeveritySchema = z.enum(["low", "medium", "high", "critical"])
4
+
5
+ export const FindingTypeSchema = z.enum([
6
+ "bug",
7
+ "security",
8
+ "performance",
9
+ "maintainability",
10
+ "test_gap",
11
+ "architecture",
12
+ ])
13
+
14
+ export const ReviewFindingSchema = z.object({
15
+ type: FindingTypeSchema,
16
+ severity: FindingSeveritySchema,
17
+ title: z.string(),
18
+ file: z.string(),
19
+ line_start: z.number().optional(),
20
+ line_end: z.number().optional(),
21
+ explanation: z.string(),
22
+ suggested_fix: z.string().optional(),
23
+ confidence: z.number().min(0).max(1),
24
+ })
25
+
26
+ export const ModelReviewSchema = z.object({
27
+ summary: z.string(),
28
+ severity: FindingSeveritySchema,
29
+ confidence: z.number().min(0).max(1),
30
+ findings: z.array(ReviewFindingSchema),
31
+ merge_blocking: z.boolean(),
32
+ needs_human_attention: z.boolean(),
33
+ })
34
+
35
+ export const CritiqueOutputSchema = z.object({
36
+ agreed_findings: z.array(z.string()),
37
+ disputed_findings: z.array(
38
+ z.object({ finding: z.string(), reason: z.string() })
39
+ ),
40
+ missed_issues: z.array(ReviewFindingSchema),
41
+ overall_assessment: z.string(),
42
+ revised_severity: FindingSeveritySchema,
43
+ })
44
+
45
+ export const CritiqueResponseSchema = z.object({
46
+ accepted: z.array(z.string()),
47
+ disputed: z.array(
48
+ z.object({ critique: z.string(), rebuttal: z.string() })
49
+ ),
50
+ revised_findings: z.array(ReviewFindingSchema),
51
+ final_summary: z.string(),
52
+ })
53
+
54
+ export function parseModelReview(raw: string): z.infer<typeof ModelReviewSchema> {
55
+ const json = extractJson(raw)
56
+ return ModelReviewSchema.parse(json)
57
+ }
58
+
59
+ export function parseCritiqueOutput(raw: string): z.infer<typeof CritiqueOutputSchema> {
60
+ const json = extractJson(raw)
61
+ return CritiqueOutputSchema.parse(json)
62
+ }
63
+
64
+ export function parseCritiqueResponse(raw: string): z.infer<typeof CritiqueResponseSchema> {
65
+ const json = extractJson(raw)
66
+ return CritiqueResponseSchema.parse(json)
67
+ }
68
+
69
+ function extractJson(text: string): unknown {
70
+ const match = text.match(/\{[\s\S]*\}/)
71
+ if (!match) throw new Error("No JSON object found in model response")
72
+ return JSON.parse(match[0])
73
+ }