mergestorm 0.3.9 → 0.3.15

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/README.md +67 -24
  2. package/dist/api.d.ts +106 -40
  3. package/dist/api.js +245 -9
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/branches.d.ts +6 -2
  7. package/dist/commands/branches.js +29 -14
  8. package/dist/commands/browse.d.ts +39 -0
  9. package/dist/commands/browse.js +259 -0
  10. package/dist/commands/credits.js +30 -12
  11. package/dist/commands/help.d.ts +5 -0
  12. package/dist/commands/help.js +93 -0
  13. package/dist/commands/jobs.d.ts +11 -2
  14. package/dist/commands/jobs.js +64 -15
  15. package/dist/commands/login.js +32 -10
  16. package/dist/commands/logout.js +2 -1
  17. package/dist/commands/pr.d.ts +22 -0
  18. package/dist/commands/pr.js +127 -0
  19. package/dist/commands/queue.d.ts +12 -0
  20. package/dist/commands/queue.js +160 -0
  21. package/dist/commands/registry.d.ts +1 -1
  22. package/dist/commands/registry.js +115 -33
  23. package/dist/commands/review-client.d.ts +106 -0
  24. package/dist/commands/review-client.js +399 -0
  25. package/dist/commands/review.d.ts +36 -6
  26. package/dist/commands/review.js +395 -145
  27. package/dist/commands/settings.d.ts +17 -0
  28. package/dist/commands/settings.js +89 -0
  29. package/dist/commands/skill.d.ts +20 -0
  30. package/dist/commands/skill.js +145 -0
  31. package/dist/commands/stack.d.ts +36 -2
  32. package/dist/commands/stack.js +341 -113
  33. package/dist/commands/status.d.ts +18 -2
  34. package/dist/commands/status.js +236 -21
  35. package/dist/commands/whoami.d.ts +3 -1
  36. package/dist/commands/whoami.js +21 -7
  37. package/dist/errors.d.ts +18 -2
  38. package/dist/errors.js +22 -1
  39. package/dist/git-stack.d.ts +2 -0
  40. package/dist/git-stack.js +4 -0
  41. package/dist/git.d.ts +12 -1
  42. package/dist/git.js +52 -7
  43. package/dist/shell.d.ts +1 -1
  44. package/dist/shell.js +39 -28
  45. package/dist/stack-dto.d.ts +266 -0
  46. package/dist/stack-dto.js +7 -0
  47. package/dist/submit-pr-base.d.ts +31 -0
  48. package/dist/submit-pr-base.js +69 -0
  49. package/dist/ui/ansi.d.ts +2 -0
  50. package/dist/ui/ansi.js +2 -0
  51. package/dist/ui/banner.d.ts +34 -2
  52. package/dist/ui/banner.js +170 -79
  53. package/dist/ui/box.d.ts +13 -9
  54. package/dist/ui/box.js +28 -44
  55. package/dist/ui/findings.d.ts +4 -1
  56. package/dist/ui/findings.js +14 -7
  57. package/dist/ui/job-envelope.d.ts +68 -0
  58. package/dist/ui/job-envelope.js +35 -0
  59. package/dist/ui/line-tabs.d.ts +27 -0
  60. package/dist/ui/line-tabs.js +170 -0
  61. package/dist/ui/logo.d.ts +10 -2
  62. package/dist/ui/logo.js +13 -9
  63. package/dist/ui/present.d.ts +10 -0
  64. package/dist/ui/present.js +13 -0
  65. package/dist/ui/prompt.d.ts +148 -0
  66. package/dist/ui/prompt.js +433 -71
  67. package/dist/ui/select.js +1 -1
  68. package/dist/ui/tabs.d.ts +125 -0
  69. package/dist/ui/tabs.js +480 -0
  70. package/dist/ui/usage.d.ts +29 -1
  71. package/dist/ui/usage.js +71 -2
  72. package/dist/ui/width.d.ts +26 -0
  73. package/dist/ui/width.js +262 -0
  74. package/package.json +6 -1
  75. package/skill/SKILL.md +98 -0
  76. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
@@ -0,0 +1,106 @@
1
+ import { type PrVortexReview } from "../api.js";
2
+ import type { Config } from "../config.js";
3
+ import type { ReviewJobRow } from "../ui/job-envelope.js";
4
+ export declare const REVIEW_POLL_INTERVAL_MS = 2000;
5
+ export declare const REVIEW_POLL_DEFAULT_TIMEOUT_MS = 480000;
6
+ export declare const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
7
+ export declare const REVIEW_POLL_BACKOFF_MS = 250;
8
+ export declare const REVIEW_POLL_MAX_INTERVAL_MS = 5000;
9
+ export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
10
+ export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
11
+ /** Same thread slug contract as POST /api/v1/reviews. */
12
+ export declare const REVIEW_THREAD_SLUG_RE: RegExp;
13
+ export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
14
+ export declare const MAX_CONTEXT_FILES = 10;
15
+ export declare const MAX_CONTEXT_FILE_CHARS = 16000;
16
+ export declare const MAX_CONTEXT_FILES_TOTAL = 60000;
17
+ export declare const MAX_IDEMPOTENCY_KEY_CHARS = 128;
18
+ export type ReviewInput = {
19
+ thread: string;
20
+ branch: string;
21
+ repo?: string;
22
+ baseLabel: string;
23
+ headLabel: string;
24
+ diff: string;
25
+ files: {
26
+ path: string;
27
+ content: string;
28
+ }[];
29
+ };
30
+ export declare function collectReviewInput(base: string, head: string, cwd?: string, sandboxRoot?: string): Promise<ReviewInput | null>;
31
+ export type SubmitReviewOptions = {
32
+ routerMode?: string;
33
+ specialists?: string[];
34
+ context?: string;
35
+ contextFiles?: {
36
+ path: string;
37
+ content: string;
38
+ }[];
39
+ idempotencyKey?: string;
40
+ webhookUrl?: string;
41
+ signal?: AbortSignal;
42
+ };
43
+ export type ReviewContextPayload = {
44
+ context?: string;
45
+ contextFiles?: {
46
+ path: string;
47
+ content: string;
48
+ }[];
49
+ };
50
+ export type LoadReviewContextOptions = {
51
+ context?: string;
52
+ contextFiles: string[];
53
+ cwd?: string;
54
+ readStdin?: () => Promise<string>;
55
+ /** Reject contextFiles that resolve outside this directory (agent-driven callers). */
56
+ sandboxCwd?: string;
57
+ };
58
+ /** Load --context / --context-file and enforce the API caps client-side. */
59
+ export declare function loadReviewContext(opts: LoadReviewContextOptions): Promise<ReviewContextPayload>;
60
+ export declare function submitReview(cfg: Config, input: ReviewInput, opts?: SubmitReviewOptions): Promise<{
61
+ status: number;
62
+ row: ReviewJobRow;
63
+ retryAfterSeconds?: number;
64
+ }>;
65
+ /** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
66
+ export declare function transientRetryWaitMs(input: {
67
+ retryAfterSeconds?: number;
68
+ failureCount: number;
69
+ random?: () => number;
70
+ }): number;
71
+ /** Keep the default 2s cadence for a minute, then ramp toward 5s. */
72
+ export declare function stretchedPollIntervalMs(elapsedMs: number, intervalMs: number): number;
73
+ export declare function isTransientReviewPollStatus(status: number): boolean;
74
+ export declare function isTransientReviewPollError(err: unknown): boolean;
75
+ export declare class ReviewPollTimeoutError extends Error {
76
+ readonly jobId: string;
77
+ readonly lastRow: ReviewJobRow;
78
+ constructor(jobId: string, lastRow: ReviewJobRow);
79
+ }
80
+ export declare class PrReviewPollTimeoutError extends Error {
81
+ readonly owner: string;
82
+ readonly repo: string;
83
+ readonly prNumber: number;
84
+ readonly lastEnvelope: PrVortexReview | null;
85
+ constructor(owner: string, repo: string, prNumber: number, lastEnvelope: PrVortexReview | null);
86
+ }
87
+ export type PollReviewOptions = {
88
+ timeoutMs?: number;
89
+ intervalMs?: number;
90
+ signal?: AbortSignal;
91
+ onTick?: (kind: "progress" | "retry") => void;
92
+ /** Rethrow per-request api_timeout instead of folding it into a poll timeout (interactive detach). */
93
+ propagateTimeout?: boolean;
94
+ /** Test seam; production uses `setTimeout`. */
95
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
96
+ /** Test seam; production uses `Date.now`. */
97
+ now?: () => number;
98
+ /** Test seam for backoff jitter; production uses `Math.random`. */
99
+ random?: () => number;
100
+ };
101
+ export type PollPrVortexReviewOptions = PollReviewOptions & {
102
+ afterSha?: string;
103
+ };
104
+ /** Poll the DB-only PR review endpoint until the requested pass is resting. */
105
+ export declare function pollPrVortexReview(cfg: Config, owner: string, repo: string, prNumber: number, opts?: PollPrVortexReviewOptions): Promise<PrVortexReview>;
106
+ export declare function pollReview(cfg: Config, jobId: string, opts?: PollReviewOptions): Promise<ReviewJobRow>;
@@ -0,0 +1,399 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import { apiFetch } from "../api.js";
4
+ import { CommandError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage } from "../errors.js";
5
+ import { changedNamesInSandbox, collectChangedFiles, git, parseGithubOriginRepo, repoRoot, } from "../git.js";
6
+ export const REVIEW_POLL_INTERVAL_MS = 2_000;
7
+ export const REVIEW_POLL_DEFAULT_TIMEOUT_MS = 480_000;
8
+ export const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
9
+ export const REVIEW_POLL_BACKOFF_MS = 250;
10
+ export const REVIEW_POLL_MAX_INTERVAL_MS = 5_000;
11
+ export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
12
+ export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
13
+ /** Same thread slug contract as POST /api/v1/reviews. */
14
+ export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
15
+ export const MAX_CONTEXT_TEXT_CHARS = 60_000;
16
+ export const MAX_CONTEXT_FILES = 10;
17
+ export const MAX_CONTEXT_FILE_CHARS = 16_000;
18
+ export const MAX_CONTEXT_FILES_TOTAL = 60_000;
19
+ export const MAX_IDEMPOTENCY_KEY_CHARS = 128;
20
+ export async function collectReviewInput(base, head, cwd = process.cwd(), sandboxRoot) {
21
+ let diff;
22
+ try {
23
+ const names = await changedNamesInSandbox(base, head, cwd, sandboxRoot);
24
+ if (names.length === 0) {
25
+ diff = "";
26
+ }
27
+ else {
28
+ const root = repoRoot(cwd);
29
+ const specs = names.map((name) => `:(literal)${relative(cwd, join(root, name))}`);
30
+ diff = git(["diff", `${base}...${head}`, "--", ...specs], cwd);
31
+ }
32
+ }
33
+ catch (err) {
34
+ const detail = err instanceof Error ? err.message : String(err);
35
+ throw new CommandError(`Could not diff ${base}...${head}: ${detail}. Try \`mergestorm review <base>\` with your trunk branch.`, 2, "usage");
36
+ }
37
+ if (!diff.trim())
38
+ return null;
39
+ const files = await collectChangedFiles(base, head, cwd, sandboxRoot);
40
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).trim() || "local";
41
+ const thread = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
42
+ const originRepo = parseGithubOriginRepo(cwd);
43
+ return {
44
+ thread,
45
+ branch,
46
+ ...(originRepo ? { repo: `${originRepo.owner}/${originRepo.repo}` } : {}),
47
+ baseLabel: base,
48
+ headLabel: head,
49
+ diff,
50
+ files,
51
+ };
52
+ }
53
+ async function defaultReadStdin() {
54
+ const chunks = [];
55
+ for await (const chunk of process.stdin) {
56
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
57
+ }
58
+ return Buffer.concat(chunks).toString("utf8");
59
+ }
60
+ function contextUsage(message) {
61
+ return new CommandError(message, REVIEW_EXIT.usage, "usage");
62
+ }
63
+ /** Load --context / --context-file and enforce the API caps client-side. */
64
+ export async function loadReviewContext(opts) {
65
+ const cwd = opts.cwd ?? process.cwd();
66
+ let context;
67
+ if (opts.context !== undefined) {
68
+ const raw = opts.context === "-"
69
+ ? await (opts.readStdin ?? defaultReadStdin)()
70
+ : opts.context;
71
+ context = raw.trim();
72
+ if (!context) {
73
+ throw contextUsage("--context is empty");
74
+ }
75
+ if (context.length > MAX_CONTEXT_TEXT_CHARS) {
76
+ throw contextUsage(`--context is ${context.length} chars; max is ${MAX_CONTEXT_TEXT_CHARS}`);
77
+ }
78
+ }
79
+ if (opts.contextFiles.length > MAX_CONTEXT_FILES) {
80
+ throw contextUsage(`--context-file may be repeated at most ${MAX_CONTEXT_FILES} times`);
81
+ }
82
+ const contextFiles = [];
83
+ let total = 0;
84
+ for (const rawPath of opts.contextFiles) {
85
+ const abs = resolve(cwd, rawPath);
86
+ if (opts.sandboxCwd) {
87
+ let sandboxReal;
88
+ let targetReal;
89
+ try {
90
+ [sandboxReal, targetReal] = await Promise.all([
91
+ realpath(resolve(opts.sandboxCwd)),
92
+ realpath(abs),
93
+ ]);
94
+ }
95
+ catch {
96
+ throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
97
+ }
98
+ const relToSandbox = relative(sandboxReal, targetReal);
99
+ if (relToSandbox === ".." || relToSandbox.startsWith(`..${sep}`)) {
100
+ throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
101
+ }
102
+ }
103
+ let content;
104
+ try {
105
+ content = await readFile(abs, "utf8");
106
+ }
107
+ catch (err) {
108
+ const detail = err instanceof Error ? err.message : String(err);
109
+ throw contextUsage(`could not read --context-file ${rawPath}: ${detail}`);
110
+ }
111
+ if (content.length > MAX_CONTEXT_FILE_CHARS) {
112
+ throw contextUsage(`--context-file ${rawPath} is ${content.length} chars; max is ${MAX_CONTEXT_FILE_CHARS} per file`);
113
+ }
114
+ total += content.length;
115
+ if (total > MAX_CONTEXT_FILES_TOTAL) {
116
+ throw contextUsage(`--context-file total is ${total} chars; max is ${MAX_CONTEXT_FILES_TOTAL}`);
117
+ }
118
+ contextFiles.push({ path: relative(cwd, abs), content });
119
+ }
120
+ return {
121
+ ...(context ? { context } : {}),
122
+ ...(contextFiles.length > 0 ? { contextFiles } : {}),
123
+ };
124
+ }
125
+ export async function submitReview(cfg, input, opts = {}) {
126
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg, "/api/v1/reviews", {
127
+ method: "POST",
128
+ json: {
129
+ thread: input.thread,
130
+ branch: input.branch,
131
+ ...(input.repo ? { repo: input.repo } : {}),
132
+ base_label: input.baseLabel,
133
+ head_label: input.headLabel,
134
+ diff: input.diff,
135
+ files: input.files,
136
+ ...(opts.routerMode ? { router_mode: opts.routerMode } : {}),
137
+ ...(opts.specialists?.length ? { specialists: opts.specialists } : {}),
138
+ ...(opts.context ? { context: opts.context } : {}),
139
+ ...(opts.contextFiles?.length ? { context_files: opts.contextFiles } : {}),
140
+ ...(opts.idempotencyKey ? { idempotency_key: opts.idempotencyKey } : {}),
141
+ ...(opts.webhookUrl ? { webhook_url: opts.webhookUrl } : {}),
142
+ },
143
+ signal: opts.signal,
144
+ });
145
+ return {
146
+ status,
147
+ row: body && typeof body === "object" ? body : {},
148
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
149
+ };
150
+ }
151
+ /** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
152
+ export function transientRetryWaitMs(input) {
153
+ const exp = REVIEW_POLL_BACKOFF_MS * 2 ** Math.max(0, input.failureCount - 1);
154
+ const jitter = 0.5 + (input.random ?? Math.random)() * 0.5;
155
+ const backoff = Math.min(REVIEW_POLL_MAX_INTERVAL_MS, Math.round(exp * jitter));
156
+ const fromHeader = input.retryAfterSeconds != null && Number.isFinite(input.retryAfterSeconds)
157
+ ? Math.round(input.retryAfterSeconds * 1000)
158
+ : 0;
159
+ return Math.max(fromHeader, backoff);
160
+ }
161
+ /** Keep the default 2s cadence for a minute, then ramp toward 5s. */
162
+ export function stretchedPollIntervalMs(elapsedMs, intervalMs) {
163
+ if (intervalMs !== REVIEW_POLL_INTERVAL_MS)
164
+ return intervalMs;
165
+ if (elapsedMs < REVIEW_POLL_STRETCH_AFTER_MS)
166
+ return intervalMs;
167
+ const t = Math.min(1, (elapsedMs - REVIEW_POLL_STRETCH_AFTER_MS) / REVIEW_POLL_STRETCH_SPAN_MS);
168
+ return Math.round(intervalMs + t * (REVIEW_POLL_MAX_INTERVAL_MS - intervalMs));
169
+ }
170
+ function sleep(ms, signal) {
171
+ return new Promise((resolve, reject) => {
172
+ if (signal?.aborted) {
173
+ reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
174
+ return;
175
+ }
176
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
177
+ const timer = setTimeout(() => {
178
+ cleanup();
179
+ resolve();
180
+ }, ms);
181
+ const onAbort = () => {
182
+ clearTimeout(timer);
183
+ cleanup();
184
+ reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
185
+ };
186
+ signal?.addEventListener("abort", onAbort, { once: true });
187
+ });
188
+ }
189
+ export function isTransientReviewPollStatus(status) {
190
+ return status === 408 || status === 429 || status >= 500;
191
+ }
192
+ export function isTransientReviewPollError(err) {
193
+ if (!(err instanceof Error))
194
+ return false;
195
+ if (err.name === "AbortError")
196
+ return false;
197
+ if (err instanceof CommandError)
198
+ return err.code === "api_timeout";
199
+ if (err instanceof TypeError && /fetch|network|connection/i.test(err.message))
200
+ return true;
201
+ return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(err.message);
202
+ }
203
+ export class ReviewPollTimeoutError extends Error {
204
+ jobId;
205
+ lastRow;
206
+ constructor(jobId, lastRow) {
207
+ super(`Timed out waiting for review ${jobId}`);
208
+ this.name = "ReviewPollTimeoutError";
209
+ this.jobId = jobId;
210
+ this.lastRow = lastRow;
211
+ }
212
+ }
213
+ export class PrReviewPollTimeoutError extends Error {
214
+ owner;
215
+ repo;
216
+ prNumber;
217
+ lastEnvelope;
218
+ constructor(owner, repo, prNumber, lastEnvelope) {
219
+ super(`Timed out waiting for PR review ${owner}/${repo}#${prNumber}`);
220
+ this.name = "PrReviewPollTimeoutError";
221
+ this.owner = owner;
222
+ this.repo = repo;
223
+ this.prNumber = prNumber;
224
+ this.lastEnvelope = lastEnvelope;
225
+ }
226
+ }
227
+ function shaMatches(actual, expected) {
228
+ const left = actual?.trim().toLowerCase() ?? "";
229
+ const right = expected.trim().toLowerCase();
230
+ return Boolean(left && right.length >= 7 && (left.startsWith(right) || right.startsWith(left)));
231
+ }
232
+ /** Poll the DB-only PR review endpoint until the requested pass is resting. */
233
+ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {}) {
234
+ const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
235
+ const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
236
+ const now = opts.now ?? Date.now;
237
+ const wait = opts.sleep ?? sleep;
238
+ const startedAt = now();
239
+ const deadline = startedAt + timeoutMs;
240
+ const query = new URLSearchParams({
241
+ owner,
242
+ repo,
243
+ pr_number: String(prNumber),
244
+ });
245
+ if (opts.afterSha)
246
+ query.set("after_sha", opts.afterSha);
247
+ let transientFailures = 0;
248
+ let lastEnvelope = null;
249
+ const sleepBeforeRetry = async (retryAfterSeconds) => {
250
+ const waitMs = Math.min(transientRetryWaitMs({
251
+ retryAfterSeconds,
252
+ failureCount: transientFailures,
253
+ random: opts.random,
254
+ }), Math.max(0, deadline - now()));
255
+ if (waitMs > 0)
256
+ await wait(waitMs, opts.signal);
257
+ };
258
+ const sleepBeforePoll = async () => {
259
+ const waitMs = Math.min(stretchedPollIntervalMs(now() - startedAt, intervalMs), Math.max(0, deadline - now()));
260
+ if (waitMs > 0)
261
+ await wait(waitMs, opts.signal);
262
+ };
263
+ while (now() < deadline) {
264
+ let poll;
265
+ try {
266
+ poll = await apiFetch(cfg, `/api/v1/stacks/pr-review?${query.toString()}`, {
267
+ signal: opts.signal,
268
+ timeoutMs: Math.max(1, Math.min(30_000, deadline - now())),
269
+ });
270
+ }
271
+ catch (err) {
272
+ if (isTransientReviewPollError(err) &&
273
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
274
+ transientFailures += 1;
275
+ opts.onTick?.("retry");
276
+ await sleepBeforeRetry();
277
+ continue;
278
+ }
279
+ if (isCommandErrorCode(err, "api_timeout")) {
280
+ if (opts.propagateTimeout)
281
+ throw err;
282
+ break;
283
+ }
284
+ if (isTransientReviewPollError(err)) {
285
+ const detail = err instanceof Error ? err.message : String(err);
286
+ throw new CommandError(`Poll failed: ${detail}`, REVIEW_EXIT.failed, "review_failed");
287
+ }
288
+ throw err;
289
+ }
290
+ if (poll.status === 404) {
291
+ transientFailures = 0;
292
+ opts.onTick?.("progress");
293
+ await sleepBeforePoll();
294
+ continue;
295
+ }
296
+ if (poll.status !== 200) {
297
+ if (isTransientReviewPollStatus(poll.status) &&
298
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
299
+ transientFailures += 1;
300
+ opts.onTick?.("retry");
301
+ await sleepBeforeRetry(poll.retryAfterSeconds);
302
+ continue;
303
+ }
304
+ if (poll.status === 429) {
305
+ throw new CommandError(rateLimitedMessage(poll.retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds: poll.retryAfterSeconds });
306
+ }
307
+ throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, REVIEW_EXIT.failed, "review_failed");
308
+ }
309
+ transientFailures = 0;
310
+ if (!poll.body || typeof poll.body !== "object") {
311
+ throw new CommandError("Poll failed: invalid PR review response", REVIEW_EXIT.failed, "review_failed");
312
+ }
313
+ lastEnvelope = poll.body;
314
+ const resting = (lastEnvelope.raw_status ?? lastEnvelope.status) !== "in_progress";
315
+ const matches = !opts.afterSha || shaMatches(lastEnvelope.head_sha, opts.afterSha);
316
+ if (resting && matches)
317
+ return lastEnvelope;
318
+ opts.onTick?.("progress");
319
+ await sleepBeforePoll();
320
+ }
321
+ throw new PrReviewPollTimeoutError(owner, repo, prNumber, lastEnvelope);
322
+ }
323
+ export async function pollReview(cfg, jobId, opts = {}) {
324
+ const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
325
+ const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
326
+ const now = opts.now ?? Date.now;
327
+ const wait = opts.sleep ?? sleep;
328
+ const startedAt = now();
329
+ const deadline = startedAt + timeoutMs;
330
+ let transientFailures = 0;
331
+ let lastRow = { job_id: jobId, status: "queued" };
332
+ const sleepBeforeRetry = async (retryAfterSeconds) => {
333
+ const waitMs = Math.min(transientRetryWaitMs({
334
+ retryAfterSeconds,
335
+ failureCount: transientFailures,
336
+ random: opts.random,
337
+ }), Math.max(0, deadline - now()));
338
+ if (waitMs > 0)
339
+ await wait(waitMs, opts.signal);
340
+ };
341
+ while (now() < deadline) {
342
+ let poll;
343
+ try {
344
+ poll = await apiFetch(cfg, `/api/v1/reviews/${jobId}`, {
345
+ signal: opts.signal,
346
+ timeoutMs: Math.max(1, Math.min(30_000, deadline - now())),
347
+ });
348
+ }
349
+ catch (err) {
350
+ if (isTransientReviewPollError(err) &&
351
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
352
+ transientFailures += 1;
353
+ opts.onTick?.("retry");
354
+ await sleepBeforeRetry();
355
+ continue;
356
+ }
357
+ if (isCommandErrorCode(err, "api_timeout")) {
358
+ if (opts.propagateTimeout)
359
+ throw err;
360
+ break;
361
+ }
362
+ if (isTransientReviewPollError(err)) {
363
+ const detail = err instanceof Error ? err.message : String(err);
364
+ throw new CommandError(`Poll failed: ${detail}`, REVIEW_EXIT.failed, "review_failed");
365
+ }
366
+ throw err;
367
+ }
368
+ if (poll.status !== 200) {
369
+ if (isTransientReviewPollStatus(poll.status) &&
370
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
371
+ transientFailures += 1;
372
+ opts.onTick?.("retry");
373
+ await sleepBeforeRetry(poll.retryAfterSeconds);
374
+ continue;
375
+ }
376
+ if (poll.status === 429) {
377
+ const message = rateLimitedMessage(poll.retryAfterSeconds);
378
+ throw new CommandError(message, REVIEW_EXIT.rate_limited, "rate_limited", {
379
+ retryAfterSeconds: poll.retryAfterSeconds,
380
+ });
381
+ }
382
+ throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, REVIEW_EXIT.failed, "review_failed");
383
+ }
384
+ transientFailures = 0;
385
+ lastRow =
386
+ poll.body && typeof poll.body === "object"
387
+ ? { ...poll.body, job_id: jobId }
388
+ : { job_id: jobId, status: "failed", error: "Invalid review response" };
389
+ if (lastRow.status === "queued" || lastRow.status === "in_progress") {
390
+ opts.onTick?.("progress");
391
+ const waitMs = Math.min(stretchedPollIntervalMs(now() - startedAt, intervalMs), Math.max(0, deadline - now()));
392
+ if (waitMs > 0)
393
+ await wait(waitMs, opts.signal);
394
+ continue;
395
+ }
396
+ return lastRow;
397
+ }
398
+ throw new ReviewPollTimeoutError(jobId, lastRow);
399
+ }
@@ -1,20 +1,50 @@
1
+ import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus } from "./review-client.js";
2
+ export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
3
+ /** Same public specialist allowlist as the API contract and migration 117. */
4
+ export declare const VORTEX_ROUTER_MODES: readonly ["off", "standard", "max", "manual"];
5
+ /** Standard/Max fleet caps; Manual is capped at Max. */
6
+ export declare const VORTEX_ROUTER_CAPS: {
7
+ readonly standard: 3;
8
+ readonly max: 5;
9
+ };
10
+ /**
11
+ * Pinnable L0 lanes only. Keep this list byte-identical to
12
+ * `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
13
+ * (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
14
+ * (phase-gated). Those L1 lanes are not local pins.
15
+ */
16
+ export declare const VORTEX_SPECIALIST_IDS: readonly ["security", "performance", "architecture", "tests", "data", "api", "frontend"];
17
+ type VortexRouterMode = (typeof VORTEX_ROUTER_MODES)[number];
18
+ type VortexSpecialistId = (typeof VORTEX_SPECIALIST_IDS)[number];
1
19
  /**
2
20
  * Diff base when the user omits args[0]. Prefers origin/HEAD / main / master
3
21
  * via discoverTrunk (same as stack create) instead of hardcoding "main".
4
22
  */
23
+ export type ParsedReviewArgs = {
24
+ base?: string;
25
+ head?: string;
26
+ router?: VortexRouterMode;
27
+ specialists: VortexSpecialistId[];
28
+ format: "pretty" | "json";
29
+ wait: boolean;
30
+ timeoutMs: number;
31
+ context?: string;
32
+ contextFiles: string[];
33
+ idempotencyKey?: string;
34
+ webhookUrl?: string;
35
+ thread?: string;
36
+ };
37
+ /** Parse the human and machine-safe `review` flags. */
38
+ export declare function parseReviewArgs(args: string[]): ParsedReviewArgs;
5
39
  export declare function resolveReviewBase(explicit: string | undefined, cwd?: string): string;
6
40
  export type ReviewOptions = {
7
41
  signal?: AbortSignal;
8
42
  /** When true, show Ctrl+C detach hint during poll. */
9
43
  interactive?: boolean;
44
+ /** Test seam; production uses the two-second poll interval. */
45
+ pollIntervalMs?: number;
10
46
  };
11
- /** Max consecutive transient poll failures before surfacing an error. */
12
- export declare const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
13
47
  export declare function reviewStatusHint(jobId: string): string;
14
48
  /** Append recovery hint once a job id exists (idempotent). */
15
49
  export declare function withReviewJobRecovery(message: string, jobId: string): string;
16
- /** HTTP statuses worth retrying during review poll (not 401/402/404). */
17
- export declare function isTransientReviewPollStatus(status: number): boolean;
18
- /** Network / request-timeout errors worth retrying during review poll. */
19
- export declare function isTransientReviewPollError(err: unknown): boolean;
20
50
  export declare function cmdReview(args: string[], opts?: ReviewOptions): Promise<void>;