@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,244 @@
1
+ import Anthropic from "@anthropic-ai/sdk"
2
+ import * as core from "@actions/core"
3
+ import type { ModelClient, ReviewRequest, CritiqueRequest, CritiqueResponseRequest } from "./types"
4
+ import type { ModelReview, CritiqueOutput, CritiqueResponse, ReviewFinding, TokenUsage } from "../types"
5
+ import { parseModelReview, parseCritiqueOutput, parseCritiqueResponse } from "../schemas/review"
6
+
7
+ const REVIEW_SYSTEM = `You are a principal engineer conducting a code review. You are thorough, skeptical, and focused on long-term code health.
8
+
9
+ Your priorities:
10
+ 1. Intent mismatch — does the code do what the PR says it does?
11
+ 2. Hidden edge cases — what breaks under unusual input or concurrency?
12
+ 3. Correctness risks — logic errors, off-by-one, race conditions, null derefs
13
+ 4. Security — injection, XSS, credential exposure, unsafe deserialization
14
+ 5. Test gaps — what should be tested that isn't?
15
+ 6. Maintainability — will this be clear in 6 months?
16
+
17
+ You are NOT a style checker. Only flag real problems that affect correctness, security, or maintainability.
18
+
19
+ Respond with a JSON object matching this exact schema:
20
+ {
21
+ "summary": "2-3 sentence overall assessment",
22
+ "severity": "low | medium | high | critical",
23
+ "confidence": 0.0-1.0,
24
+ "findings": [
25
+ {
26
+ "type": "bug | security | performance | maintainability | test_gap | architecture",
27
+ "severity": "low | medium | high | critical",
28
+ "title": "short title",
29
+ "file": "path/to/file",
30
+ "line_start": 1,
31
+ "line_end": 1,
32
+ "explanation": "detailed explanation of the issue",
33
+ "suggested_fix": "optional concrete fix",
34
+ "confidence": 0.0-1.0
35
+ }
36
+ ],
37
+ "merge_blocking": true/false,
38
+ "needs_human_attention": true/false
39
+ }
40
+
41
+ If the code is clean, return an empty findings array with a positive summary.
42
+ Output ONLY the JSON object, no markdown fences.`
43
+
44
+ const CRITIQUE_SYSTEM = `You are a principal engineer reviewing another engineer's code review findings.
45
+
46
+ For each finding from the other reviewer:
47
+ 1. If you agree, acknowledge it and explain why it matters
48
+ 2. If you disagree, explain specifically why with code references
49
+ 3. Identify any issues they missed entirely
50
+
51
+ You MUST acknowledge the strongest points from the other review, even if you disagree with others.
52
+
53
+ Respond with a JSON object:
54
+ {
55
+ "agreed_findings": ["description of each finding you agree with"],
56
+ "disputed_findings": [{"finding": "what they said", "reason": "why you disagree"}],
57
+ "missed_issues": [same schema as review findings above],
58
+ "overall_assessment": "your synthesis",
59
+ "revised_severity": "low | medium | high | critical"
60
+ }
61
+
62
+ Output ONLY the JSON object, no markdown fences.`
63
+
64
+ export class AnthropicClient implements ModelClient {
65
+ name = "anthropic" as const
66
+ private client: Anthropic
67
+ private model: string
68
+
69
+ constructor(apiKey: string, model: string) {
70
+ this.client = new Anthropic({ apiKey })
71
+ this.model = model
72
+ }
73
+
74
+ async review(req: ReviewRequest): Promise<{ review: ModelReview; usage: TokenUsage }> {
75
+ const contextBlock = buildContextBlock(req)
76
+
77
+ const response = await this.call(
78
+ REVIEW_SYSTEM,
79
+ `Review this pull request:\n\n${contextBlock}\n\n${req.userPrompt}`
80
+ )
81
+
82
+ const parsed = parseModelReview(response.text)
83
+ return {
84
+ review: normalizeReview(parsed, "anthropic"),
85
+ usage: response.usage,
86
+ }
87
+ }
88
+
89
+ async critique(req: CritiqueRequest): Promise<{ critique: CritiqueOutput; usage: TokenUsage }> {
90
+ const findingsJson = JSON.stringify(req.otherModelReview, null, 2)
91
+
92
+ const response = await this.call(
93
+ CRITIQUE_SYSTEM,
94
+ `Here are the findings from another code reviewer:\n\n${findingsJson}\n\nCritique these findings. What did they get right? What did they get wrong? What did they miss?`
95
+ )
96
+
97
+ const parsed = parseCritiqueOutput(response.text)
98
+ return {
99
+ critique: {
100
+ agreedFindings: parsed.agreed_findings,
101
+ disputedFindings: parsed.disputed_findings,
102
+ missedIssues: parsed.missed_issues.map((f) => normalizeFinding(f, "anthropic")),
103
+ overallAssessment: parsed.overall_assessment,
104
+ revisedSeverity: parsed.revised_severity,
105
+ },
106
+ usage: response.usage,
107
+ }
108
+ }
109
+
110
+ async respondToCritique(req: CritiqueResponseRequest): Promise<{
111
+ response: CritiqueResponse
112
+ usage: TokenUsage
113
+ }> {
114
+ const critiqueJson = JSON.stringify(req.critique, null, 2)
115
+ const originalJson = JSON.stringify(req.originalReview, null, 2)
116
+
117
+ const response = await this.call(
118
+ REVIEW_SYSTEM,
119
+ `Your original review:\n${originalJson}\n\nA senior reviewer critiqued your findings:\n${critiqueJson}\n\nRespond to the critique. Accept valid points. Defend findings you believe are correct. Output revised findings.`
120
+ )
121
+
122
+ const text = response.text
123
+ let parsed
124
+ try {
125
+ parsed = parseCritiqueResponse(text)
126
+ } catch {
127
+ return {
128
+ response: {
129
+ accepted: [],
130
+ disputed: [],
131
+ revisedFindings: req.originalReview.findings,
132
+ finalSummary: text.substring(0, 500),
133
+ },
134
+ usage: response.usage,
135
+ }
136
+ }
137
+
138
+ return {
139
+ response: {
140
+ accepted: parsed.accepted,
141
+ disputed: parsed.disputed,
142
+ revisedFindings: parsed.revised_findings.map((f) => normalizeFinding(f, "anthropic")),
143
+ finalSummary: parsed.final_summary,
144
+ },
145
+ usage: response.usage,
146
+ }
147
+ }
148
+
149
+ async chat(system: string, user: string): Promise<{ text: string; usage: TokenUsage }> {
150
+ return this.call(system, user)
151
+ }
152
+
153
+ private async call(
154
+ system: string,
155
+ user: string,
156
+ retries = 1
157
+ ): Promise<{ text: string; usage: TokenUsage }> {
158
+ for (let attempt = 0; attempt <= retries; attempt++) {
159
+ try {
160
+ const response = await this.client.messages.create({
161
+ model: this.model,
162
+ max_tokens: 4096,
163
+ system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
164
+ messages: [{ role: "user", content: user }],
165
+ })
166
+
167
+ const text = response.content
168
+ .filter((b): b is Anthropic.TextBlock => b.type === "text")
169
+ .map((b) => b.text)
170
+ .join("")
171
+
172
+ return {
173
+ text,
174
+ usage: {
175
+ input: response.usage.input_tokens,
176
+ output: response.usage.output_tokens,
177
+ },
178
+ }
179
+ } catch (err: unknown) {
180
+ const isRetryable = err instanceof Error && ("status" in err) &&
181
+ [429, 500, 529].includes((err as { status: number }).status)
182
+
183
+ if (attempt < retries && isRetryable) {
184
+ core.warning(`Anthropic call failed (attempt ${attempt + 1}), retrying...`)
185
+ await sleep(2000 * (attempt + 1))
186
+ continue
187
+ }
188
+ throw err
189
+ }
190
+ }
191
+
192
+ throw new Error("Anthropic call exhausted retries")
193
+ }
194
+ }
195
+
196
+ function buildContextBlock(req: ReviewRequest): string {
197
+ const parts: string[] = []
198
+ const ctx = req.context
199
+
200
+ if (ctx.repoPolicies.reviewRulesMarkdown) {
201
+ parts.push(`## Repository Review Rules\n${ctx.repoPolicies.reviewRulesMarkdown}`)
202
+ }
203
+ if (ctx.repoPolicies.architectureNotes) {
204
+ parts.push(`## Architecture Notes\n${ctx.repoPolicies.architectureNotes}`)
205
+ }
206
+
207
+ return parts.join("\n\n")
208
+ }
209
+
210
+ function normalizeReview(
211
+ parsed: ReturnType<typeof parseModelReview>,
212
+ source: "anthropic" | "openai"
213
+ ): ModelReview {
214
+ return {
215
+ summary: parsed.summary,
216
+ severity: parsed.severity,
217
+ confidence: parsed.confidence,
218
+ findings: parsed.findings.map((f) => normalizeFinding(f, source)),
219
+ mergeBlocking: parsed.merge_blocking,
220
+ needsHumanAttention: parsed.needs_human_attention,
221
+ }
222
+ }
223
+
224
+ function normalizeFinding(
225
+ f: { type: string; severity: string; title: string; file: string; line_start?: number; line_end?: number; explanation: string; suggested_fix?: string; confidence: number },
226
+ source: "anthropic" | "openai"
227
+ ): ReviewFinding {
228
+ return {
229
+ type: f.type as ReviewFinding["type"],
230
+ severity: f.severity as ReviewFinding["severity"],
231
+ title: f.title,
232
+ file: f.file,
233
+ lineStart: f.line_start,
234
+ lineEnd: f.line_end,
235
+ explanation: f.explanation,
236
+ suggestedFix: f.suggested_fix,
237
+ confidence: f.confidence,
238
+ source,
239
+ }
240
+ }
241
+
242
+ function sleep(ms: number): Promise<void> {
243
+ return new Promise((resolve) => setTimeout(resolve, ms))
244
+ }
@@ -0,0 +1,242 @@
1
+ import OpenAI from "openai"
2
+ import * as core from "@actions/core"
3
+ import type { ModelClient, ReviewRequest, CritiqueRequest, CritiqueResponseRequest } from "./types"
4
+ import type { ModelReview, CritiqueOutput, CritiqueResponse, ReviewFinding, TokenUsage } from "../types"
5
+ import { parseModelReview, parseCritiqueOutput, parseCritiqueResponse } from "../schemas/review"
6
+
7
+ const REVIEW_SYSTEM = `You are a repo-aware implementation engineer reviewing code changes. You are precise, concrete, and focused on correctness at the code level.
8
+
9
+ Your priorities:
10
+ 1. Concrete bugs — specific lines with specific problems
11
+ 2. Input validation — missing checks, unhandled errors, boundary conditions
12
+ 3. Type safety — incorrect types, unsafe casts, missing null checks
13
+ 4. Resource handling — unclosed connections, memory leaks, missing cleanup
14
+ 5. Test coverage — specific test cases that should exist for this change
15
+ 6. Convention violations — does this match the rest of the codebase?
16
+
17
+ Focus on code-level specifics, not high-level architecture. Be precise about file paths and line numbers.
18
+
19
+ Respond with a JSON object matching this exact schema:
20
+ {
21
+ "summary": "2-3 sentence code-level assessment",
22
+ "severity": "low | medium | high | critical",
23
+ "confidence": 0.0-1.0,
24
+ "findings": [
25
+ {
26
+ "type": "bug | security | performance | maintainability | test_gap | architecture",
27
+ "severity": "low | medium | high | critical",
28
+ "title": "short title",
29
+ "file": "path/to/file",
30
+ "line_start": 1,
31
+ "line_end": 1,
32
+ "explanation": "detailed explanation with code reference",
33
+ "suggested_fix": "concrete code fix",
34
+ "confidence": 0.0-1.0
35
+ }
36
+ ],
37
+ "merge_blocking": true/false,
38
+ "needs_human_attention": true/false
39
+ }
40
+
41
+ If the code is clean, return an empty findings array with a positive summary.
42
+ Output ONLY the JSON object, no markdown fences.`
43
+
44
+ const CRITIQUE_RESPONSE_SYSTEM = `You are an implementation engineer responding to a senior reviewer's critique of your code review.
45
+
46
+ For each critique:
47
+ 1. If they're right, accept it explicitly
48
+ 2. If you disagree, provide a specific rebuttal with code references
49
+ 3. Update your findings list based on valid critiques
50
+
51
+ You MUST accept valid critiques. Do not be defensive.
52
+
53
+ Respond with a JSON object:
54
+ {
55
+ "accepted": ["description of each accepted critique"],
56
+ "disputed": [{"critique": "what they said", "rebuttal": "your specific rebuttal"}],
57
+ "revised_findings": [same schema as review findings — your FINAL revised list],
58
+ "final_summary": "updated assessment incorporating valid critiques"
59
+ }
60
+
61
+ Output ONLY the JSON object, no markdown fences.`
62
+
63
+ export class OpenAIClient implements ModelClient {
64
+ name = "openai" as const
65
+ private client: OpenAI
66
+ private model: string
67
+
68
+ constructor(apiKey: string, model: string) {
69
+ this.client = new OpenAI({ apiKey })
70
+ this.model = model
71
+ }
72
+
73
+ async review(req: ReviewRequest): Promise<{ review: ModelReview; usage: TokenUsage }> {
74
+ const contextBlock = buildContextBlock(req)
75
+
76
+ const response = await this.call(
77
+ REVIEW_SYSTEM,
78
+ `Review this pull request:\n\n${contextBlock}\n\n${req.userPrompt}`
79
+ )
80
+
81
+ const parsed = parseModelReview(response.text)
82
+ return {
83
+ review: normalizeReview(parsed, "openai"),
84
+ usage: response.usage,
85
+ }
86
+ }
87
+
88
+ async critique(req: CritiqueRequest): Promise<{ critique: CritiqueOutput; usage: TokenUsage }> {
89
+ const findingsJson = JSON.stringify(req.otherModelReview, null, 2)
90
+
91
+ const response = await this.call(
92
+ REVIEW_SYSTEM,
93
+ `Here are findings from another reviewer:\n\n${findingsJson}\n\nCritique these findings. What's correct? What's wrong? What's missing?\n\nRespond as JSON: {"agreed_findings":[],"disputed_findings":[{"finding":"","reason":""}],"missed_issues":[],"overall_assessment":"","revised_severity":""}`
94
+ )
95
+
96
+ const parsed = parseCritiqueOutput(response.text)
97
+ return {
98
+ critique: {
99
+ agreedFindings: parsed.agreed_findings,
100
+ disputedFindings: parsed.disputed_findings,
101
+ missedIssues: parsed.missed_issues.map((f) => normalizeFinding(f, "openai")),
102
+ overallAssessment: parsed.overall_assessment,
103
+ revisedSeverity: parsed.revised_severity,
104
+ },
105
+ usage: response.usage,
106
+ }
107
+ }
108
+
109
+ async respondToCritique(req: CritiqueResponseRequest): Promise<{
110
+ response: CritiqueResponse
111
+ usage: TokenUsage
112
+ }> {
113
+ const critiqueJson = JSON.stringify(req.critique, null, 2)
114
+ const originalJson = JSON.stringify(req.originalReview, null, 2)
115
+
116
+ const response = await this.call(
117
+ CRITIQUE_RESPONSE_SYSTEM,
118
+ `Your original review:\n${originalJson}\n\nA senior reviewer critiqued your findings:\n${critiqueJson}\n\nRespond to the critique.`
119
+ )
120
+
121
+ let parsed
122
+ try {
123
+ parsed = parseCritiqueResponse(response.text)
124
+ } catch {
125
+ return {
126
+ response: {
127
+ accepted: [],
128
+ disputed: [],
129
+ revisedFindings: req.originalReview.findings,
130
+ finalSummary: response.text.substring(0, 500),
131
+ },
132
+ usage: response.usage,
133
+ }
134
+ }
135
+
136
+ return {
137
+ response: {
138
+ accepted: parsed.accepted,
139
+ disputed: parsed.disputed,
140
+ revisedFindings: parsed.revised_findings.map((f) => normalizeFinding(f, "openai")),
141
+ finalSummary: parsed.final_summary,
142
+ },
143
+ usage: response.usage,
144
+ }
145
+ }
146
+
147
+ async chat(system: string, user: string): Promise<{ text: string; usage: import("../types").TokenUsage }> {
148
+ return this.call(system, user)
149
+ }
150
+
151
+ private async call(
152
+ system: string,
153
+ user: string,
154
+ retries = 1
155
+ ): Promise<{ text: string; usage: TokenUsage }> {
156
+ for (let attempt = 0; attempt <= retries; attempt++) {
157
+ try {
158
+ const response = await this.client.chat.completions.create({
159
+ model: this.model,
160
+ messages: [
161
+ { role: "system", content: system },
162
+ { role: "user", content: user },
163
+ ],
164
+ max_tokens: 4096,
165
+ temperature: 0.1,
166
+ response_format: { type: "json_object" },
167
+ })
168
+
169
+ const text = response.choices[0]?.message?.content || ""
170
+ return {
171
+ text,
172
+ usage: {
173
+ input: response.usage?.prompt_tokens || 0,
174
+ output: response.usage?.completion_tokens || 0,
175
+ },
176
+ }
177
+ } catch (err: unknown) {
178
+ const status = (err as { status?: number }).status
179
+ const isRetryable = status === 429 || status === 500 || status === 503
180
+
181
+ if (attempt < retries && isRetryable) {
182
+ core.warning(`OpenAI call failed (attempt ${attempt + 1}), retrying...`)
183
+ await sleep(2000 * (attempt + 1))
184
+ continue
185
+ }
186
+ throw err
187
+ }
188
+ }
189
+
190
+ throw new Error("OpenAI call exhausted retries")
191
+ }
192
+ }
193
+
194
+ function buildContextBlock(req: ReviewRequest): string {
195
+ const parts: string[] = []
196
+ const ctx = req.context
197
+
198
+ if (ctx.repoPolicies.reviewRulesMarkdown) {
199
+ parts.push(`## Repository Review Rules\n${ctx.repoPolicies.reviewRulesMarkdown}`)
200
+ }
201
+ if (ctx.repoPolicies.architectureNotes) {
202
+ parts.push(`## Architecture Notes\n${ctx.repoPolicies.architectureNotes}`)
203
+ }
204
+
205
+ return parts.join("\n\n")
206
+ }
207
+
208
+ function normalizeReview(
209
+ parsed: ReturnType<typeof parseModelReview>,
210
+ source: "anthropic" | "openai"
211
+ ): ModelReview {
212
+ return {
213
+ summary: parsed.summary,
214
+ severity: parsed.severity,
215
+ confidence: parsed.confidence,
216
+ findings: parsed.findings.map((f) => normalizeFinding(f, source)),
217
+ mergeBlocking: parsed.merge_blocking,
218
+ needsHumanAttention: parsed.needs_human_attention,
219
+ }
220
+ }
221
+
222
+ function normalizeFinding(
223
+ f: { type: string; severity: string; title: string; file: string; line_start?: number; line_end?: number; explanation: string; suggested_fix?: string; confidence: number },
224
+ source: "anthropic" | "openai"
225
+ ): ReviewFinding {
226
+ return {
227
+ type: f.type as ReviewFinding["type"],
228
+ severity: f.severity as ReviewFinding["severity"],
229
+ title: f.title,
230
+ file: f.file,
231
+ lineStart: f.line_start,
232
+ lineEnd: f.line_end,
233
+ explanation: f.explanation,
234
+ suggestedFix: f.suggested_fix,
235
+ confidence: f.confidence,
236
+ source,
237
+ }
238
+ }
239
+
240
+ function sleep(ms: number): Promise<void> {
241
+ return new Promise((resolve) => setTimeout(resolve, ms))
242
+ }