@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,29 @@
1
+ {
2
+ "name": "sentinel",
3
+ "version": "0.1.0",
4
+ "description": "Dual-model AI review bot for GitHub pull requests and issues",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "ncc build src/index.ts -o dist --source-map --license licenses.txt",
8
+ "typecheck": "tsc --noEmit",
9
+ "test": "vitest run",
10
+ "test:watch": "vitest",
11
+ "lint": "tsc --noEmit"
12
+ },
13
+ "keywords": ["github-action", "code-review", "ai", "anthropic", "openai"],
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@actions/core": "^1.11.1",
17
+ "@actions/github": "^6.0.0",
18
+ "@anthropic-ai/sdk": "^0.39.0",
19
+ "openai": "^4.85.0",
20
+ "yaml": "^2.7.0",
21
+ "zod": "^3.24.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "@vercel/ncc": "^0.38.3",
26
+ "typescript": "^5.7.0",
27
+ "vitest": "^3.0.0"
28
+ }
29
+ }
@@ -0,0 +1,265 @@
1
+ import * as fs from "fs"
2
+ import { execSync } from "child_process"
3
+ import * as core from "@actions/core"
4
+ import type { CodeContext } from "./types"
5
+
6
+ const CODE_EXTENSIONS = [
7
+ "ts", "tsx", "js", "jsx", "py", "go", "rs", "rb",
8
+ "java", "kt", "swift", "c", "cpp", "h", "cs",
9
+ ]
10
+
11
+ const MAX_FILE_SIZE = 50_000
12
+ const MAX_CONTEXT_FILES = 12
13
+
14
+ export async function analyzeCodebase(
15
+ keywords: string[],
16
+ maxFiles: number = MAX_CONTEXT_FILES
17
+ ): Promise<CodeContext> {
18
+ const allFiles = getFileTree()
19
+ const searchHits = searchForKeywords(keywords, allFiles)
20
+ const rankedFiles = rankFiles(searchHits, allFiles, keywords)
21
+ const relevantFiles = readTopFiles(rankedFiles, maxFiles)
22
+ const structure = getProjectStructure(allFiles)
23
+ const dependencies = getDependencies()
24
+
25
+ core.info(`Codebase analysis: ${allFiles.length} files, ${keywords.length} keywords, ${relevantFiles.length} relevant files`)
26
+
27
+ return { files: relevantFiles, structure, dependencies }
28
+ }
29
+
30
+ export function extractKeywords(title: string, body: string): string[] {
31
+ const text = `${title} ${body}`
32
+ const codeRefs = text.match(/`([^`]+)`/g)?.map((m) => m.replace(/`/g, "")) || []
33
+ const fileRefs = text.match(/[\w/.-]+\.\w{1,5}/g) || []
34
+ const fnRefs = text.match(/\b[a-z][a-zA-Z0-9_]+(?=\()/g) || []
35
+ const classRefs = text.match(/\b[A-Z][a-zA-Z0-9]+\b/g)?.filter((w) => w.length > 3) || []
36
+
37
+ const stopwords = new Set([
38
+ "the", "and", "for", "that", "this", "with", "from", "have", "been",
39
+ "should", "would", "could", "when", "where", "what", "which", "there",
40
+ "about", "into", "more", "some", "than", "them", "then", "these",
41
+ "Error", "Warning", "Issue", "Problem", "Bug", "Feature", "Request",
42
+ "TODO", "FIXME", "NOTE", "String", "Number", "Boolean", "Object", "Array",
43
+ ])
44
+
45
+ const all = [...codeRefs, ...fileRefs, ...fnRefs, ...classRefs]
46
+ const unique = [...new Set(all)].filter((k) => k.length > 2 && !stopwords.has(k))
47
+
48
+ return unique.slice(0, 20)
49
+ }
50
+
51
+ function getFileTree(): string[] {
52
+ try {
53
+ return execSync("git ls-files", { encoding: "utf-8", timeout: 5000 })
54
+ .split("\n")
55
+ .filter((f) => f && !f.startsWith(".git"))
56
+ } catch {
57
+ try {
58
+ return execSync("find . -type f -not -path './.git/*' -not -path './node_modules/*' | head -2000", {
59
+ encoding: "utf-8",
60
+ timeout: 5000,
61
+ })
62
+ .split("\n")
63
+ .filter(Boolean)
64
+ .map((f) => f.replace(/^\.\//, ""))
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+ }
70
+
71
+ interface SearchHit {
72
+ file: string
73
+ line: number
74
+ text: string
75
+ keyword: string
76
+ }
77
+
78
+ function searchForKeywords(keywords: string[], _files: string[]): SearchHit[] {
79
+ const hits: SearchHit[] = []
80
+ const extGlob = CODE_EXTENSIONS.map((e) => `--include='*.${e}'`).join(" ")
81
+
82
+ for (const keyword of keywords) {
83
+ const shellSafe = keyword.replace(/'/g, "'\\''")
84
+ try {
85
+ const output = execSync(
86
+ `grep -rn ${extGlob} -F -i '${shellSafe}' . 2>/dev/null | head -30`,
87
+ { encoding: "utf-8", timeout: 5000 }
88
+ )
89
+ for (const line of output.split("\n").filter(Boolean)) {
90
+ const match = line.match(/^\.\/(.+):(\d+):(.+)$/)
91
+ if (match) {
92
+ hits.push({
93
+ file: match[1],
94
+ line: parseInt(match[2]),
95
+ text: match[3].trim(),
96
+ keyword,
97
+ })
98
+ }
99
+ }
100
+ } catch {
101
+ // no matches
102
+ }
103
+ }
104
+
105
+ return hits
106
+ }
107
+
108
+ function rankFiles(
109
+ hits: SearchHit[],
110
+ allFiles: string[],
111
+ keywords: string[]
112
+ ): Array<{ path: string; score: number; relevance: string }> {
113
+ const scores = new Map<string, { score: number; reasons: string[] }>()
114
+
115
+ for (const hit of hits) {
116
+ const existing = scores.get(hit.file) || { score: 0, reasons: [] }
117
+ existing.score += 1
118
+ if (!existing.reasons.includes(hit.keyword)) {
119
+ existing.reasons.push(hit.keyword)
120
+ }
121
+ scores.set(hit.file, existing)
122
+ }
123
+
124
+ for (const keyword of keywords) {
125
+ for (const file of allFiles) {
126
+ const basename = file.split("/").pop() || ""
127
+ if (basename.toLowerCase().includes(keyword.toLowerCase())) {
128
+ const existing = scores.get(file) || { score: 0, reasons: [] }
129
+ existing.score += 3
130
+ existing.reasons.push(`filename match: ${keyword}`)
131
+ scores.set(file, existing)
132
+ }
133
+ }
134
+ }
135
+
136
+ return Array.from(scores.entries())
137
+ .map(([path, { score, reasons }]) => ({
138
+ path,
139
+ score,
140
+ relevance: reasons.slice(0, 3).join(", "),
141
+ }))
142
+ .sort((a, b) => b.score - a.score)
143
+ }
144
+
145
+ function readTopFiles(
146
+ ranked: Array<{ path: string; score: number; relevance: string }>,
147
+ maxFiles: number
148
+ ): Array<{ path: string; content: string; relevance: string }> {
149
+ const result: Array<{ path: string; content: string; relevance: string }> = []
150
+
151
+ for (const file of ranked.slice(0, maxFiles)) {
152
+ try {
153
+ const stat = fs.statSync(file.path)
154
+ if (stat.size > MAX_FILE_SIZE) {
155
+ result.push({
156
+ path: file.path,
157
+ content: `[File too large: ${stat.size} bytes — showing first ${MAX_FILE_SIZE} chars]\n` +
158
+ fs.readFileSync(file.path, "utf-8").substring(0, MAX_FILE_SIZE),
159
+ relevance: file.relevance,
160
+ })
161
+ } else {
162
+ result.push({
163
+ path: file.path,
164
+ content: fs.readFileSync(file.path, "utf-8"),
165
+ relevance: file.relevance,
166
+ })
167
+ }
168
+ } catch {
169
+ core.debug(`Could not read ${file.path}`)
170
+ }
171
+ }
172
+
173
+ return result
174
+ }
175
+
176
+ function getProjectStructure(files: string[]): string {
177
+ const dirs = new Set<string>()
178
+ for (const f of files) {
179
+ const parts = f.split("/")
180
+ for (let i = 1; i <= Math.min(parts.length - 1, 3); i++) {
181
+ dirs.add(parts.slice(0, i).join("/") + "/")
182
+ }
183
+ }
184
+
185
+ const sorted = Array.from(dirs).sort()
186
+ const codeFiles = files.filter((f) => CODE_EXTENSIONS.some((ext) => f.endsWith(`.${ext}`)))
187
+
188
+ const lines: string[] = []
189
+ lines.push(`Total files: ${files.length} (${codeFiles.length} code files)`)
190
+ lines.push("")
191
+ lines.push("Directories:")
192
+ for (const dir of sorted.slice(0, 40)) {
193
+ const count = files.filter((f) => f.startsWith(dir)).length
194
+ lines.push(` ${dir} (${count} files)`)
195
+ }
196
+
197
+ return lines.join("\n")
198
+ }
199
+
200
+ function getDependencies(): string {
201
+ const parts: string[] = []
202
+
203
+ if (fs.existsSync("package.json")) {
204
+ try {
205
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf-8"))
206
+ const deps = Object.keys(pkg.dependencies || {})
207
+ const devDeps = Object.keys(pkg.devDependencies || {})
208
+ parts.push(`Node.js project: ${pkg.name || "unnamed"}`)
209
+ if (deps.length) parts.push(`Dependencies: ${deps.join(", ")}`)
210
+ if (devDeps.length) parts.push(`Dev dependencies: ${devDeps.join(", ")}`)
211
+ } catch { /* */ }
212
+ }
213
+
214
+ if (fs.existsSync("requirements.txt")) {
215
+ try {
216
+ const reqs = fs.readFileSync("requirements.txt", "utf-8").split("\n").filter(Boolean).slice(0, 20)
217
+ parts.push(`Python dependencies: ${reqs.join(", ")}`)
218
+ } catch { /* */ }
219
+ }
220
+
221
+ if (fs.existsSync("go.mod")) {
222
+ parts.push("Go module project")
223
+ }
224
+
225
+ if (fs.existsSync("Cargo.toml")) {
226
+ try {
227
+ const cargoContent = fs.readFileSync("Cargo.toml", "utf-8")
228
+ const nameMatch = cargoContent.match(/^name\s*=\s*"([^"]+)"/m)
229
+ const membersMatch = cargoContent.match(/members\s*=\s*\[([^\]]+)\]/s)
230
+ const cargoName = nameMatch ? nameMatch[1] : "unnamed"
231
+
232
+ if (membersMatch) {
233
+ const members = membersMatch[1].match(/"([^"]+)"/g)?.map((m) => m.replace(/"/g, "")) || []
234
+ parts.push(`Rust workspace: ${cargoName} (members: ${members.join(", ")})`)
235
+
236
+ for (const member of members.slice(0, 8)) {
237
+ const memberToml = `${member}/Cargo.toml`
238
+ if (fs.existsSync(memberToml)) {
239
+ try {
240
+ const memberContent = fs.readFileSync(memberToml, "utf-8")
241
+ const depsSection = memberContent.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ""
242
+ const depNames = depsSection.match(/^(\w[\w-]*)\s*=/gm)?.map((d) => d.replace(/\s*=.*/, "")) || []
243
+ if (depNames.length) {
244
+ parts.push(` ${member}: ${depNames.join(", ")}`)
245
+ }
246
+ } catch { /* skip unreadable member */ }
247
+ }
248
+ }
249
+ } else {
250
+ const depsSection = cargoContent.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ""
251
+ const depNames = depsSection.match(/^(\w[\w-]*)\s*=/gm)?.map((d) => d.replace(/\s*=.*/, "")) || []
252
+ parts.push(`Rust project: ${cargoName}`)
253
+ if (depNames.length) parts.push(`Dependencies: ${depNames.join(", ")}`)
254
+ }
255
+ } catch {
256
+ parts.push("Rust/Cargo project")
257
+ }
258
+ }
259
+
260
+ if (fs.existsSync("tsconfig.json")) {
261
+ parts.push("TypeScript enabled")
262
+ }
263
+
264
+ return parts.join("\n") || "No dependency info found"
265
+ }
@@ -0,0 +1,182 @@
1
+ import * as core from "@actions/core"
2
+ import * as github from "@actions/github"
3
+ import type { ReviewContext, ChangedFile, RepoPolicies } from "./types"
4
+
5
+ type Octokit = ReturnType<typeof github.getOctokit>
6
+
7
+ export async function buildPRContext(
8
+ ctx: ReviewContext,
9
+ octokit: Octokit
10
+ ): Promise<ReviewContext> {
11
+ if (!ctx.pullRequest) return ctx
12
+
13
+ const { owner, name } = ctx.repository
14
+ const prNumber = ctx.pullRequest.number
15
+
16
+ const [files, prDetail, ciStatus] = await Promise.all([
17
+ fetchChangedFiles(octokit, owner, name, prNumber, ctx.repoPolicies),
18
+ fetchPRDetail(octokit, owner, name, prNumber),
19
+ fetchCIStatus(octokit, owner, name, ctx.pullRequest.headRef),
20
+ ])
21
+
22
+ ctx.pullRequest.changedFiles = files
23
+ ctx.pullRequest.body = prDetail.body || ctx.pullRequest.body
24
+ ctx.pullRequest.baseRef = prDetail.baseRef || ctx.pullRequest.baseRef
25
+ ctx.pullRequest.headRef = prDetail.headRef || ctx.pullRequest.headRef
26
+ ctx.pullRequest.ciStatus = ciStatus
27
+ ctx.event.trustedActor = !prDetail.isFork
28
+
29
+ const [reviewRules, architectureNotes] = await Promise.all([
30
+ fetchFileContent(octokit, owner, name, ".github/review-rules.md"),
31
+ fetchFileContent(octokit, owner, name, ".github/architecture-notes.md"),
32
+ ])
33
+
34
+ if (reviewRules) ctx.repoPolicies.reviewRulesMarkdown = reviewRules
35
+ if (architectureNotes) ctx.repoPolicies.architectureNotes = architectureNotes
36
+
37
+ core.info(`Context built: ${files.length} files, fork=${prDetail.isFork}, ci=${ciStatus || "unknown"}`)
38
+ return ctx
39
+ }
40
+
41
+ export async function buildIssueContext(
42
+ ctx: ReviewContext,
43
+ octokit: Octokit
44
+ ): Promise<ReviewContext> {
45
+ if (!ctx.issue) return ctx
46
+
47
+ const { owner, name } = ctx.repository
48
+ const comments = await fetchIssueComments(octokit, owner, name, ctx.issue.number)
49
+ ctx.issue.comments = comments
50
+
51
+ return ctx
52
+ }
53
+
54
+ async function fetchChangedFiles(
55
+ octokit: Octokit,
56
+ owner: string,
57
+ repo: string,
58
+ prNumber: number,
59
+ policies: RepoPolicies
60
+ ): Promise<ChangedFile[]> {
61
+ const files: ChangedFile[] = []
62
+
63
+ try {
64
+ const response = await octokit.rest.pulls.listFiles({
65
+ owner,
66
+ repo,
67
+ pull_number: prNumber,
68
+ per_page: 100,
69
+ })
70
+
71
+ let totalPatchChars = 0
72
+
73
+ for (const file of response.data) {
74
+ if (files.length >= policies.maxFiles) {
75
+ core.warning(`Truncated at ${policies.maxFiles} files (policy limit)`)
76
+ break
77
+ }
78
+
79
+ const patchLen = file.patch?.length || 0
80
+ if (totalPatchChars + patchLen > policies.maxPatchChars) {
81
+ core.warning(`Truncated patch content at ${policies.maxPatchChars} chars`)
82
+ files.push({
83
+ path: file.filename,
84
+ status: mapFileStatus(file.status),
85
+ additions: file.additions,
86
+ deletions: file.deletions,
87
+ })
88
+ continue
89
+ }
90
+
91
+ totalPatchChars += patchLen
92
+ files.push({
93
+ path: file.filename,
94
+ patch: file.patch,
95
+ status: mapFileStatus(file.status),
96
+ additions: file.additions,
97
+ deletions: file.deletions,
98
+ })
99
+ }
100
+ } catch (err) {
101
+ core.warning(`Failed to fetch changed files: ${err}`)
102
+ }
103
+
104
+ return files
105
+ }
106
+
107
+ async function fetchPRDetail(
108
+ octokit: Octokit,
109
+ owner: string,
110
+ repo: string,
111
+ prNumber: number
112
+ ): Promise<{ body: string; baseRef: string; headRef: string; isFork: boolean }> {
113
+ try {
114
+ const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
115
+ return {
116
+ body: data.body || "",
117
+ baseRef: data.base.ref,
118
+ headRef: data.head.ref,
119
+ isFork: data.head.repo?.fork ?? false,
120
+ }
121
+ } catch {
122
+ return { body: "", baseRef: "main", headRef: "", isFork: false }
123
+ }
124
+ }
125
+
126
+ async function fetchCIStatus(
127
+ octokit: Octokit,
128
+ owner: string,
129
+ repo: string,
130
+ ref: string
131
+ ): Promise<string | undefined> {
132
+ if (!ref) return undefined
133
+ try {
134
+ const { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref })
135
+ return data.state
136
+ } catch {
137
+ return undefined
138
+ }
139
+ }
140
+
141
+ async function fetchIssueComments(
142
+ octokit: Octokit,
143
+ owner: string,
144
+ repo: string,
145
+ issueNumber: number
146
+ ): Promise<string[]> {
147
+ try {
148
+ const { data } = await octokit.rest.issues.listComments({
149
+ owner,
150
+ repo,
151
+ issue_number: issueNumber,
152
+ per_page: 20,
153
+ })
154
+ return data.map((c) => c.body || "").filter(Boolean)
155
+ } catch {
156
+ return []
157
+ }
158
+ }
159
+
160
+ async function fetchFileContent(
161
+ octokit: Octokit,
162
+ owner: string,
163
+ repo: string,
164
+ path: string
165
+ ): Promise<string | undefined> {
166
+ try {
167
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path })
168
+ if ("content" in data && data.content) {
169
+ return Buffer.from(data.content, "base64").toString("utf-8")
170
+ }
171
+ } catch {
172
+ // file doesn't exist
173
+ }
174
+ return undefined
175
+ }
176
+
177
+ function mapFileStatus(status: string): ChangedFile["status"] {
178
+ if (status === "added") return "added"
179
+ if (status === "removed") return "removed"
180
+ if (status === "renamed") return "renamed"
181
+ return "modified"
182
+ }