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
@@ -1,14 +1,201 @@
1
- import { apiFetch, getMe } from "../api.js";
1
+ import { getMe } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
- import { CommandError, DetachedError, isCommandErrorCode } from "../errors.js";
4
- import { collectChangedFiles, git, parseGithubOriginRepo } from "../git.js";
3
+ import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
5
4
  import { discoverTrunk } from "../git-stack.js";
6
5
  import { ansi } from "../ui/ansi.js";
7
- import { renderFindings } from "../ui/findings.js";
6
+ import { formatFindingsLines } from "../ui/findings.js";
7
+ import { present } from "../ui/present.js";
8
+ import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
9
+ import { MAX_IDEMPOTENCY_KEY_CHARS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, submitReview, } from "./review-client.js";
10
+ export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
11
+ /** Same public specialist allowlist as the API contract and migration 117. */
12
+ export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
13
+ /** Standard/Max fleet caps; Manual is capped at Max. */
14
+ export const VORTEX_ROUTER_CAPS = { standard: 3, max: 5 };
8
15
  /**
9
- * Diff base when the user omits args[0]. Prefers origin/HEAD / main / master
10
- * via discoverTrunk (same as stack create) instead of hardcoding "main".
16
+ * Pinnable L0 lanes only. Keep this list byte-identical to
17
+ * `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
18
+ * (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
19
+ * (phase-gated). Those L1 lanes are not local pins.
11
20
  */
21
+ export const VORTEX_SPECIALIST_IDS = [
22
+ "security",
23
+ "performance",
24
+ "architecture",
25
+ "tests",
26
+ "data",
27
+ "api",
28
+ "frontend",
29
+ ];
30
+ function isVortexRouterMode(value) {
31
+ return typeof value === "string" && VORTEX_ROUTER_MODES.includes(value);
32
+ }
33
+ function isVortexSpecialistId(value) {
34
+ return typeof value === "string" && VORTEX_SPECIALIST_IDS.includes(value);
35
+ }
36
+ function normalizeVortexSpecialistIds(value) {
37
+ if (!Array.isArray(value))
38
+ return [];
39
+ const selected = new Set(value.filter((id) => typeof id === "string" && VORTEX_SPECIALIST_IDS.includes(id)));
40
+ return VORTEX_SPECIALIST_IDS.filter((id) => selected.has(id));
41
+ }
42
+ const REVIEW_USAGE = "usage: mergestorm review [base] [head] [--json] [--wait|--no-wait] [--timeout seconds] [--router off|standard|max|manual] [--specialists id,id] [--context text] [--context-file path] [--idempotency-key k] [--webhook-url https://…] [--thread slug]";
43
+ function usageError(message = REVIEW_USAGE) {
44
+ return new CommandError(message, REVIEW_EXIT.usage, "usage");
45
+ }
46
+ function takeFlagValue(args, i, name) {
47
+ const a = args[i];
48
+ const prefix = `--${name}=`;
49
+ const value = a === `--${name}` ? args[i + 1] : a.slice(prefix.length);
50
+ if (value === undefined || (a === `--${name}` && value.startsWith("--"))) {
51
+ throw usageError(`--${name} requires a value`);
52
+ }
53
+ return { value, i: a === `--${name}` ? i + 1 : i };
54
+ }
55
+ /** Parse the human and machine-safe `review` flags. */
56
+ export function parseReviewArgs(args) {
57
+ const positional = [];
58
+ let router;
59
+ let specialists = [];
60
+ let format = "pretty";
61
+ let wait = true;
62
+ let waitFlag = null;
63
+ let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
64
+ let context;
65
+ const contextFiles = [];
66
+ let idempotencyKey;
67
+ let webhookUrl;
68
+ let thread;
69
+ for (let i = 0; i < args.length; i += 1) {
70
+ const a = args[i];
71
+ if (a === "--json") {
72
+ format = "json";
73
+ continue;
74
+ }
75
+ if (a === "--wait" || a === "--no-wait") {
76
+ if (waitFlag && waitFlag !== a)
77
+ throw usageError("--wait and --no-wait cannot be combined");
78
+ waitFlag = a;
79
+ wait = a === "--wait";
80
+ continue;
81
+ }
82
+ if (a === "--timeout" || a.startsWith("--timeout=")) {
83
+ const value = a === "--timeout" ? args[++i] : a.slice("--timeout=".length);
84
+ const seconds = Number(value);
85
+ if (!value || !Number.isFinite(seconds) || seconds <= 0) {
86
+ throw usageError("--timeout must be a positive number of seconds");
87
+ }
88
+ timeoutMs = Math.round(seconds * 1_000);
89
+ continue;
90
+ }
91
+ if (a === "--router" || a.startsWith("--router=")) {
92
+ const value = a === "--router" ? args[++i] : a.slice("--router=".length);
93
+ if (!isVortexRouterMode(value)) {
94
+ throw usageError();
95
+ }
96
+ router = value;
97
+ continue;
98
+ }
99
+ if (a === "--specialists" || a.startsWith("--specialists=")) {
100
+ const value = a === "--specialists" ? args[++i] : a.slice("--specialists=".length);
101
+ if (!value?.trim()) {
102
+ throw usageError();
103
+ }
104
+ const raw = value.split(",").map((s) => s.trim()).filter(Boolean);
105
+ if (raw.length === 0)
106
+ throw usageError();
107
+ const unknown = raw.filter((id) => !isVortexSpecialistId(id));
108
+ if (unknown.length > 0) {
109
+ throw usageError(`Unknown specialist: ${[...new Set(unknown)].join(", ")}`);
110
+ }
111
+ specialists = normalizeVortexSpecialistIds(raw);
112
+ continue;
113
+ }
114
+ if (a === "--context-file" || a.startsWith("--context-file=")) {
115
+ const taken = takeFlagValue(args, i, "context-file");
116
+ i = taken.i;
117
+ if (!taken.value.trim())
118
+ throw usageError("--context-file requires a path");
119
+ contextFiles.push(taken.value);
120
+ continue;
121
+ }
122
+ if (a === "--context" || a.startsWith("--context=")) {
123
+ const taken = takeFlagValue(args, i, "context");
124
+ i = taken.i;
125
+ context = taken.value;
126
+ continue;
127
+ }
128
+ if (a === "--idempotency-key" || a.startsWith("--idempotency-key=")) {
129
+ const taken = takeFlagValue(args, i, "idempotency-key");
130
+ i = taken.i;
131
+ const key = taken.value.trim();
132
+ if (!key)
133
+ throw usageError("--idempotency-key requires a value");
134
+ if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
135
+ throw usageError(`--idempotency-key is ${key.length} chars; max is ${MAX_IDEMPOTENCY_KEY_CHARS}`);
136
+ }
137
+ idempotencyKey = key;
138
+ continue;
139
+ }
140
+ if (a === "--webhook-url" || a.startsWith("--webhook-url=")) {
141
+ const taken = takeFlagValue(args, i, "webhook-url");
142
+ i = taken.i;
143
+ let parsedUrl;
144
+ try {
145
+ parsedUrl = new URL(taken.value);
146
+ }
147
+ catch {
148
+ throw usageError("--webhook-url must be an https URL");
149
+ }
150
+ if (parsedUrl.protocol !== "https:") {
151
+ throw usageError("--webhook-url must be an https URL");
152
+ }
153
+ webhookUrl = parsedUrl.toString();
154
+ continue;
155
+ }
156
+ if (a === "--thread" || a.startsWith("--thread=")) {
157
+ const taken = takeFlagValue(args, i, "thread");
158
+ i = taken.i;
159
+ const slug = taken.value.trim();
160
+ if (!REVIEW_THREAD_SLUG_RE.test(slug)) {
161
+ throw usageError("--thread must be 1–120 chars [A-Za-z0-9._/-]");
162
+ }
163
+ thread = slug;
164
+ continue;
165
+ }
166
+ if (a.startsWith("-")) {
167
+ throw usageError(`unknown flag: ${a}`);
168
+ }
169
+ positional.push(a);
170
+ }
171
+ if (positional.length > 2)
172
+ throw usageError();
173
+ if (specialists.length > 0 && router == null)
174
+ router = "manual";
175
+ if (router === "manual" && specialists.length === 0) {
176
+ throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id");
177
+ }
178
+ if (router === "manual" && specialists.length > VORTEX_ROUTER_CAPS.max) {
179
+ throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id (manual runs at most 5 specialists)");
180
+ }
181
+ if (router === "off" && specialists.length > 0) {
182
+ throw usageError("--router off cannot be combined with --specialists");
183
+ }
184
+ return {
185
+ base: positional[0],
186
+ head: positional[1],
187
+ router,
188
+ specialists,
189
+ format,
190
+ wait,
191
+ timeoutMs,
192
+ context,
193
+ contextFiles,
194
+ idempotencyKey,
195
+ webhookUrl,
196
+ thread,
197
+ };
198
+ }
12
199
  export function resolveReviewBase(explicit, cwd = process.cwd()) {
13
200
  if (explicit)
14
201
  return explicit;
@@ -16,25 +203,9 @@ export function resolveReviewBase(explicit, cwd = process.cwd()) {
16
203
  return discoverTrunk(cwd);
17
204
  }
18
205
  catch {
19
- throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]");
206
+ throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]", REVIEW_EXIT.usage, "usage");
20
207
  }
21
208
  }
22
- function sleep(ms, signal) {
23
- return new Promise((resolve, reject) => {
24
- if (signal?.aborted) {
25
- reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
26
- return;
27
- }
28
- const t = setTimeout(resolve, ms);
29
- const onAbort = () => {
30
- clearTimeout(t);
31
- reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
32
- };
33
- signal?.addEventListener("abort", onAbort, { once: true });
34
- });
35
- }
36
- /** Max consecutive transient poll failures before surfacing an error. */
37
- export const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
38
209
  export function reviewStatusHint(jobId) {
39
210
  return `Check: mergestorm status ${jobId}`;
40
211
  }
@@ -45,149 +216,228 @@ export function withReviewJobRecovery(message, jobId) {
45
216
  return message;
46
217
  return `${message.replace(/\s+$/, "")}\n${hint}`;
47
218
  }
48
- /** HTTP statuses worth retrying during review poll (not 401/402/404). */
49
- export function isTransientReviewPollStatus(status) {
50
- return status === 408 || status === 429 || status >= 500;
51
- }
52
- /** Network / request-timeout errors worth retrying during review poll. */
53
- export function isTransientReviewPollError(err) {
54
- if (!(err instanceof Error))
55
- return false;
56
- if (err.name === "AbortError")
57
- return false;
58
- if (err instanceof CommandError) {
59
- return err.code === "api_timeout";
60
- }
61
- if (err instanceof TypeError && /fetch|network|connection/i.test(err.message))
62
- return true;
63
- return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(err.message);
64
- }
65
219
  function asPollCommandError(err, jobId) {
66
220
  if (err instanceof CommandError) {
67
- return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code);
221
+ return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code, err.retryAfterSeconds !== undefined
222
+ ? { retryAfterSeconds: err.retryAfterSeconds }
223
+ : undefined);
68
224
  }
69
225
  const detail = err instanceof Error ? err.message : String(err);
70
226
  return new CommandError(withReviewJobRecovery(`Poll failed: ${detail}`, jobId));
71
227
  }
72
228
  export async function cmdReview(args, opts = {}) {
73
- const base = resolveReviewBase(args[0]);
74
- const head = args[1] || "HEAD";
75
- const cfg = await loadConfig();
76
- const meBefore = opts.interactive ? await getMe(cfg) : null;
77
- console.log(`Collecting diff ${base}...${head} …`);
78
- let diff;
79
- try {
80
- diff = git(["diff", `${base}...${head}`]);
81
- }
82
- catch (err) {
83
- const detail = err instanceof Error ? err.message : String(err);
84
- throw new CommandError(`Could not diff ${base}...${head}: ${detail}. Try \`mergestorm review <base>\` with your trunk branch.`);
85
- }
86
- if (!diff.trim()) {
87
- console.log("No changes to review.");
88
- return;
89
- }
90
- const files = await collectChangedFiles(base, head);
91
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim() || "local";
92
- const thread = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
93
- const originRepo = parseGithubOriginRepo();
94
- console.log(`Submitting review (${Buffer.byteLength(diff, "utf8")} byte diff, ${files.length} file(s))…`);
95
- const { status, body } = await apiFetch(cfg, "/api/v1/reviews", {
96
- method: "POST",
97
- json: {
98
- thread,
99
- branch,
100
- ...(originRepo ? { repo: `${originRepo.owner}/${originRepo.repo}` } : {}),
101
- base_label: base,
102
- head_label: head,
103
- diff,
104
- files,
105
- },
106
- signal: opts.signal,
107
- });
108
- if (status === 402) {
109
- throw new CommandError("Monthly review limit reached. Upgrade at https://mergestorm.ai/billing");
110
- }
111
- if (status !== 202 && status !== 200) {
112
- throw new CommandError(`Failed: ${JSON.stringify(body, null, 2)}`);
113
- }
114
- const job = body;
115
- const waitHint = opts.interactive
116
- ? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
117
- : " Waiting…";
118
- console.log(`Job ${job.job_id} (${job.status}).${waitHint}`);
119
- let transientFailures = 0;
229
+ const parsed = parseReviewArgs(args);
230
+ const base = resolveReviewBase(parsed.base);
231
+ const head = parsed.head || "HEAD";
232
+ const json = parsed.format === "json";
233
+ const interactive = Boolean(opts.interactive && !json);
234
+ const fallback = {
235
+ baseLabel: base,
236
+ headLabel: head,
237
+ routerMode: parsed.router ?? null,
238
+ specialistsRequested: parsed.specialists,
239
+ };
240
+ let jsonPrinted = false;
241
+ let webhookSecret = null;
242
+ const printJson = (row, extra = {}) => {
243
+ const rowWithSecret = {
244
+ ...(row ?? {}),
245
+ ...(webhookSecret ? { webhook_secret: webhookSecret } : {}),
246
+ };
247
+ console.log(JSON.stringify(toReviewJobEnvelope(rowWithSecret, { ...fallback, ...extra })));
248
+ jsonPrinted = true;
249
+ };
250
+ const progress = (message) => {
251
+ if (json)
252
+ console.error(message);
253
+ else
254
+ console.log(message);
255
+ };
120
256
  try {
121
- for (let i = 0; i < 90; i += 1) {
122
- await sleep(2000, opts.signal);
123
- let poll;
124
- try {
125
- poll = await apiFetch(cfg, `/api/v1/reviews/${job.job_id}`, { signal: opts.signal });
257
+ const cfg = await loadConfig();
258
+ const meBefore = interactive ? await getMe(cfg) : null;
259
+ progress(`Collecting diff ${base}...${head} …`);
260
+ const input = await collectReviewInput(base, head);
261
+ if (!input) {
262
+ if (json)
263
+ printJson(null, { status: "no_changes" });
264
+ else
265
+ console.log("No changes to review.");
266
+ return;
267
+ }
268
+ if (parsed.thread)
269
+ input.thread = parsed.thread;
270
+ fallback.threadSlug = input.thread;
271
+ const extras = await loadReviewContext({
272
+ context: parsed.context,
273
+ contextFiles: parsed.contextFiles,
274
+ });
275
+ progress(`Submitting review (${Buffer.byteLength(input.diff, "utf8")} byte diff, ${input.files.length} file(s))…`);
276
+ if (parsed.router) {
277
+ progress(`Fleet router=${parsed.router}${parsed.specialists.length ? ` specialists=${parsed.specialists.join(",")}` : ""}`);
278
+ }
279
+ let submitted;
280
+ try {
281
+ submitted = await submitReview(cfg, input, {
282
+ routerMode: parsed.router,
283
+ specialists: parsed.specialists,
284
+ context: extras.context,
285
+ contextFiles: extras.contextFiles,
286
+ idempotencyKey: parsed.idempotencyKey,
287
+ webhookUrl: parsed.webhookUrl,
288
+ signal: opts.signal,
289
+ });
290
+ }
291
+ catch (err) {
292
+ if (!isTransientReviewPollError(err))
293
+ throw err;
294
+ const message = err instanceof Error ? err.message : String(err);
295
+ if (json)
296
+ printJson(null, { status: "in_progress", error: message });
297
+ throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
298
+ }
299
+ const jobId = submitted.row.job_id ?? submitted.row.id ?? null;
300
+ fallback.jobId = jobId;
301
+ webhookSecret = submitted.row.webhook_secret ?? null;
302
+ if (submitted.status === 402) {
303
+ const message = "Review limit reached for this period. Upgrade at https://mergestorm.ai/billing";
304
+ if (json)
305
+ printJson(submitted.row, { status: "quota_exceeded", error: message });
306
+ throw new CommandError(message, REVIEW_EXIT.quota, "review_quota");
307
+ }
308
+ if (submitted.status === 429) {
309
+ const retryAfterSeconds = submitted.retryAfterSeconds;
310
+ const message = rateLimitedMessage(retryAfterSeconds);
311
+ if (json) {
312
+ printJson(submitted.row, {
313
+ status: "rate_limited",
314
+ error: message,
315
+ retryAfterSeconds,
316
+ });
126
317
  }
127
- catch (err) {
128
- if (opts.interactive && err instanceof Error && err.name === "AbortError") {
129
- throw err;
130
- }
131
- if (opts.interactive && isCommandErrorCode(err, "api_timeout")) {
132
- throw err;
133
- }
134
- if (isTransientReviewPollError(err) &&
135
- transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
136
- transientFailures += 1;
137
- if (opts.interactive)
138
- process.stdout.write("?");
139
- continue;
140
- }
141
- throw asPollCommandError(err, job.job_id);
318
+ throw new CommandError(message, REVIEW_EXIT.rate_limited, "rate_limited", {
319
+ retryAfterSeconds,
320
+ });
321
+ }
322
+ if (submitted.status !== 202 && submitted.status !== 200) {
323
+ const message = `Failed: ${JSON.stringify(submitted.row, null, 2)}`;
324
+ if (json)
325
+ printJson(submitted.row, { status: "failed", error: message });
326
+ throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
327
+ }
328
+ if (!jobId) {
329
+ const message = "Review API returned no job_id";
330
+ if (json)
331
+ printJson(submitted.row, { status: "failed", error: message });
332
+ throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
333
+ }
334
+ if (!parsed.wait) {
335
+ if (json)
336
+ printJson(submitted.row, { jobId });
337
+ else
338
+ console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).`);
339
+ return;
340
+ }
341
+ if (!json) {
342
+ const waitHint = interactive
343
+ ? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
344
+ : " Waiting…";
345
+ console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).${waitHint}`);
346
+ }
347
+ let row;
348
+ try {
349
+ row = await pollReview(cfg, jobId, {
350
+ timeoutMs: parsed.timeoutMs,
351
+ intervalMs: opts.pollIntervalMs,
352
+ signal: opts.signal,
353
+ propagateTimeout: interactive,
354
+ onTick(kind) {
355
+ if (json)
356
+ console.error(kind === "retry" ? "?" : ".");
357
+ else
358
+ process.stdout.write(kind === "retry" ? "?" : ".");
359
+ },
360
+ });
361
+ }
362
+ catch (err) {
363
+ if (opts.interactive && err instanceof Error && err.name === "AbortError") {
364
+ throw new DetachedError(jobId);
142
365
  }
143
- if (poll.status !== 200) {
144
- if (isTransientReviewPollStatus(poll.status) &&
145
- transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
146
- transientFailures += 1;
147
- if (opts.interactive)
148
- process.stdout.write("?");
149
- continue;
366
+ if (interactive && isCommandErrorCode(err, "api_timeout")) {
367
+ throw new DetachedError(jobId);
368
+ }
369
+ if (err instanceof ReviewPollTimeoutError) {
370
+ const message = withReviewJobRecovery("Timed out waiting for review.", jobId);
371
+ if (json) {
372
+ printJson(err.lastRow, {
373
+ jobId,
374
+ status: err.lastRow.status ?? "in_progress",
375
+ error: "Timed out waiting for review.",
376
+ });
150
377
  }
151
- throw new CommandError(withReviewJobRecovery(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, job.job_id));
378
+ throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
152
379
  }
153
- transientFailures = 0;
154
- const row = poll.body;
155
- if (row.status === "queued" || row.status === "in_progress") {
156
- process.stdout.write(".");
157
- continue;
380
+ if (isCommandErrorCode(err, "rate_limited")) {
381
+ if (json) {
382
+ printJson(null, {
383
+ status: "rate_limited",
384
+ error: err.message,
385
+ retryAfterSeconds: err.retryAfterSeconds,
386
+ });
387
+ }
388
+ throw asPollCommandError(err, jobId);
158
389
  }
390
+ throw asPollCommandError(err, jobId);
391
+ }
392
+ if (!json)
159
393
  console.log("");
160
- if (row.status === "failed" || row.status === "quota_exceeded") {
161
- throw new CommandError(withReviewJobRecovery(`Review ${row.status}: ${row.error ?? ""}`, job.job_id));
162
- }
163
- console.log(`\nVerdict: ${ansi.bold(row.verdict ?? "comment")}`);
164
- console.log(row.summary ?? "");
165
- renderFindings(row.findings, { emptyMessage: true });
166
- if (opts.interactive && meBefore) {
167
- const meAfter = await getMe(cfg);
168
- if (meAfter) {
169
- const beforeLeft = meBefore.usage.standard.remaining ?? 0;
170
- const afterLeft = meAfter.usage.standard.remaining ?? 0;
171
- const used = Math.max(0, beforeLeft - afterLeft);
172
- if (used > 0) {
173
- console.log(ansi.dim(`\n${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
174
- }
394
+ if (row.status === "failed" || row.status === "quota_exceeded") {
395
+ const message = withReviewJobRecovery(`Review ${row.status}: ${row.error ?? ""}`, jobId);
396
+ if (json)
397
+ printJson(row, { jobId });
398
+ throw new CommandError(message, row.status === "quota_exceeded" ? REVIEW_EXIT.quota : REVIEW_EXIT.failed, row.status === "quota_exceeded" ? "review_quota" : "review_failed");
399
+ }
400
+ if (json) {
401
+ printJson(row, { jobId });
402
+ return;
403
+ }
404
+ const ran = row.specialists_run ?? row.findings?.specialists_run ?? [];
405
+ const result = [
406
+ `Verdict: ${ansi.bold(row.verdict ?? "comment")}`,
407
+ ];
408
+ if (ran.length > 0)
409
+ result.push(ansi.dim(`Specialists: ${ran.join(", ")}`));
410
+ if (row.summary)
411
+ result.push(row.summary);
412
+ result.push(...formatFindingsLines(row.findings, {
413
+ emptyMessage: true,
414
+ }));
415
+ if (interactive && meBefore) {
416
+ const meAfter = await getMe(cfg);
417
+ if (meAfter) {
418
+ const beforeLeft = meBefore.usage.standard.remaining ?? 0;
419
+ const afterLeft = meAfter.usage.standard.remaining ?? 0;
420
+ const used = Math.max(0, beforeLeft - afterLeft);
421
+ if (used > 0) {
422
+ result.push(ansi.dim(`${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
175
423
  }
176
424
  }
177
- return;
178
425
  }
426
+ await present("Review", result);
179
427
  }
180
428
  catch (err) {
181
- if (opts.interactive && err instanceof Error && err.name === "AbortError") {
182
- throw new DetachedError(job.job_id);
183
- }
184
- if (opts.interactive && isCommandErrorCode(err, "api_timeout")) {
185
- throw new DetachedError(job.job_id);
429
+ if (err instanceof DetachedError)
430
+ throw err;
431
+ if (json &&
432
+ !jsonPrinted &&
433
+ !(err instanceof Error && err.name === "AbortError")) {
434
+ const message = err instanceof Error ? err.message : String(err);
435
+ printJson(null, { status: "failed", error: message });
186
436
  }
187
- if (err instanceof CommandError) {
188
- throw new CommandError(withReviewJobRecovery(err.message, job.job_id), err.exitCode, err.code);
437
+ if (err instanceof CommandError &&
438
+ (err.code === "auth_invalid" || err.code === "missing_api_key")) {
439
+ throw new CommandError(err.message, REVIEW_EXIT.auth, err.code);
189
440
  }
190
441
  throw err;
191
442
  }
192
- throw new CommandError(withReviewJobRecovery("Timed out waiting for review.", job.job_id));
193
443
  }
@@ -0,0 +1,17 @@
1
+ import { type SettingsPatch, type SettingsResponse, type SettingsWritableKey } from "../api.js";
2
+ /**
3
+ * CLI flag → Bearer settings key. on|off only. The connected flags are
4
+ * read-only on the API and deliberately have no flag here.
5
+ */
6
+ export declare const SETTINGS_FLAGS: Record<string, SettingsWritableKey>;
7
+ export type SettingsArgs = {
8
+ json: boolean;
9
+ patch: SettingsPatch;
10
+ };
11
+ /** Parse `settings` argv. Accepts `--flag on|off` and `--flag=on|off`. */
12
+ export declare function parseSettingsArgs(args: string[]): SettingsArgs;
13
+ /** Static printout of the GET/PATCH response shape (rows match the Config tab). */
14
+ export declare function formatSettingsLines(settings: SettingsResponse): string[];
15
+ export declare function cmdSettings(args: string[], opts?: {
16
+ mode?: "oneshot" | "shell";
17
+ }): Promise<void>;