mergestorm 0.3.9 → 0.3.13

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 (43) hide show
  1. package/README.md +57 -13
  2. package/dist/api.d.ts +27 -38
  3. package/dist/api.js +81 -4
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/credits.js +33 -12
  7. package/dist/commands/login.js +28 -7
  8. package/dist/commands/registry.d.ts +1 -1
  9. package/dist/commands/registry.js +26 -19
  10. package/dist/commands/review-client.d.ts +93 -0
  11. package/dist/commands/review-client.js +289 -0
  12. package/dist/commands/review.d.ts +30 -6
  13. package/dist/commands/review.js +385 -146
  14. package/dist/commands/stack.d.ts +34 -2
  15. package/dist/commands/stack.js +252 -58
  16. package/dist/commands/status.d.ts +16 -2
  17. package/dist/commands/status.js +221 -21
  18. package/dist/errors.d.ts +18 -2
  19. package/dist/errors.js +22 -1
  20. package/dist/git-stack.d.ts +2 -0
  21. package/dist/git-stack.js +4 -0
  22. package/dist/git.d.ts +12 -1
  23. package/dist/git.js +52 -7
  24. package/dist/shell.d.ts +1 -1
  25. package/dist/shell.js +6 -3
  26. package/dist/stack-dto.d.ts +185 -0
  27. package/dist/stack-dto.js +7 -0
  28. package/dist/submit-pr-base.d.ts +31 -0
  29. package/dist/submit-pr-base.js +69 -0
  30. package/dist/ui/banner.js +7 -0
  31. package/dist/ui/box.d.ts +13 -9
  32. package/dist/ui/box.js +28 -44
  33. package/dist/ui/findings.d.ts +2 -0
  34. package/dist/ui/findings.js +2 -1
  35. package/dist/ui/job-envelope.d.ts +68 -0
  36. package/dist/ui/job-envelope.js +35 -0
  37. package/dist/ui/prompt.d.ts +91 -0
  38. package/dist/ui/prompt.js +241 -62
  39. package/dist/ui/usage.d.ts +25 -1
  40. package/dist/ui/usage.js +50 -1
  41. package/dist/ui/width.d.ts +26 -0
  42. package/dist/ui/width.js +262 -0
  43. package/package.json +5 -1
@@ -1,14 +1,194 @@
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
6
  import { renderFindings } from "../ui/findings.js";
8
- /**
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".
11
- */
7
+ import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
8
+ 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";
9
+ export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
10
+ /** Same public specialist allowlist as the API contract and migration 117. */
11
+ export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
12
+ /** Standard/Max fleet caps; Manual is capped at Max. */
13
+ export const VORTEX_ROUTER_CAPS = { standard: 3, max: 5 };
14
+ export const VORTEX_SPECIALIST_IDS = [
15
+ "security",
16
+ "performance",
17
+ "architecture",
18
+ "tests",
19
+ "data",
20
+ "api",
21
+ "frontend",
22
+ ];
23
+ function isVortexRouterMode(value) {
24
+ return typeof value === "string" && VORTEX_ROUTER_MODES.includes(value);
25
+ }
26
+ function isVortexSpecialistId(value) {
27
+ return typeof value === "string" && VORTEX_SPECIALIST_IDS.includes(value);
28
+ }
29
+ function normalizeVortexSpecialistIds(value) {
30
+ if (!Array.isArray(value))
31
+ return [];
32
+ const selected = new Set(value.filter((id) => typeof id === "string" && VORTEX_SPECIALIST_IDS.includes(id)));
33
+ return VORTEX_SPECIALIST_IDS.filter((id) => selected.has(id));
34
+ }
35
+ 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]";
36
+ function usageError(message = REVIEW_USAGE) {
37
+ return new CommandError(message, REVIEW_EXIT.usage, "usage");
38
+ }
39
+ function takeFlagValue(args, i, name) {
40
+ const a = args[i];
41
+ const prefix = `--${name}=`;
42
+ const value = a === `--${name}` ? args[i + 1] : a.slice(prefix.length);
43
+ if (value === undefined || (a === `--${name}` && value.startsWith("--"))) {
44
+ throw usageError(`--${name} requires a value`);
45
+ }
46
+ return { value, i: a === `--${name}` ? i + 1 : i };
47
+ }
48
+ /** Parse the human and machine-safe `review` flags. */
49
+ export function parseReviewArgs(args) {
50
+ const positional = [];
51
+ let router;
52
+ let specialists = [];
53
+ let format = "pretty";
54
+ let wait = true;
55
+ let waitFlag = null;
56
+ let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
57
+ let context;
58
+ const contextFiles = [];
59
+ let idempotencyKey;
60
+ let webhookUrl;
61
+ let thread;
62
+ for (let i = 0; i < args.length; i += 1) {
63
+ const a = args[i];
64
+ if (a === "--json") {
65
+ format = "json";
66
+ continue;
67
+ }
68
+ if (a === "--wait" || a === "--no-wait") {
69
+ if (waitFlag && waitFlag !== a)
70
+ throw usageError("--wait and --no-wait cannot be combined");
71
+ waitFlag = a;
72
+ wait = a === "--wait";
73
+ continue;
74
+ }
75
+ if (a === "--timeout" || a.startsWith("--timeout=")) {
76
+ const value = a === "--timeout" ? args[++i] : a.slice("--timeout=".length);
77
+ const seconds = Number(value);
78
+ if (!value || !Number.isFinite(seconds) || seconds <= 0) {
79
+ throw usageError("--timeout must be a positive number of seconds");
80
+ }
81
+ timeoutMs = Math.round(seconds * 1_000);
82
+ continue;
83
+ }
84
+ if (a === "--router" || a.startsWith("--router=")) {
85
+ const value = a === "--router" ? args[++i] : a.slice("--router=".length);
86
+ if (!isVortexRouterMode(value)) {
87
+ throw usageError();
88
+ }
89
+ router = value;
90
+ continue;
91
+ }
92
+ if (a === "--specialists" || a.startsWith("--specialists=")) {
93
+ const value = a === "--specialists" ? args[++i] : a.slice("--specialists=".length);
94
+ if (!value?.trim()) {
95
+ throw usageError();
96
+ }
97
+ const raw = value.split(",").map((s) => s.trim()).filter(Boolean);
98
+ if (raw.length === 0)
99
+ throw usageError();
100
+ const unknown = raw.filter((id) => !isVortexSpecialistId(id));
101
+ if (unknown.length > 0) {
102
+ throw usageError(`Unknown specialist: ${[...new Set(unknown)].join(", ")}`);
103
+ }
104
+ specialists = normalizeVortexSpecialistIds(raw);
105
+ continue;
106
+ }
107
+ if (a === "--context-file" || a.startsWith("--context-file=")) {
108
+ const taken = takeFlagValue(args, i, "context-file");
109
+ i = taken.i;
110
+ if (!taken.value.trim())
111
+ throw usageError("--context-file requires a path");
112
+ contextFiles.push(taken.value);
113
+ continue;
114
+ }
115
+ if (a === "--context" || a.startsWith("--context=")) {
116
+ const taken = takeFlagValue(args, i, "context");
117
+ i = taken.i;
118
+ context = taken.value;
119
+ continue;
120
+ }
121
+ if (a === "--idempotency-key" || a.startsWith("--idempotency-key=")) {
122
+ const taken = takeFlagValue(args, i, "idempotency-key");
123
+ i = taken.i;
124
+ const key = taken.value.trim();
125
+ if (!key)
126
+ throw usageError("--idempotency-key requires a value");
127
+ if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
128
+ throw usageError(`--idempotency-key is ${key.length} chars; max is ${MAX_IDEMPOTENCY_KEY_CHARS}`);
129
+ }
130
+ idempotencyKey = key;
131
+ continue;
132
+ }
133
+ if (a === "--webhook-url" || a.startsWith("--webhook-url=")) {
134
+ const taken = takeFlagValue(args, i, "webhook-url");
135
+ i = taken.i;
136
+ let parsedUrl;
137
+ try {
138
+ parsedUrl = new URL(taken.value);
139
+ }
140
+ catch {
141
+ throw usageError("--webhook-url must be an https URL");
142
+ }
143
+ if (parsedUrl.protocol !== "https:") {
144
+ throw usageError("--webhook-url must be an https URL");
145
+ }
146
+ webhookUrl = parsedUrl.toString();
147
+ continue;
148
+ }
149
+ if (a === "--thread" || a.startsWith("--thread=")) {
150
+ const taken = takeFlagValue(args, i, "thread");
151
+ i = taken.i;
152
+ const slug = taken.value.trim();
153
+ if (!REVIEW_THREAD_SLUG_RE.test(slug)) {
154
+ throw usageError("--thread must be 1–120 chars [A-Za-z0-9._/-]");
155
+ }
156
+ thread = slug;
157
+ continue;
158
+ }
159
+ if (a.startsWith("-")) {
160
+ throw usageError(`unknown flag: ${a}`);
161
+ }
162
+ positional.push(a);
163
+ }
164
+ if (positional.length > 2)
165
+ throw usageError();
166
+ if (specialists.length > 0 && router == null)
167
+ router = "manual";
168
+ if (router === "manual" && specialists.length === 0) {
169
+ throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id");
170
+ }
171
+ if (router === "manual" && specialists.length > VORTEX_ROUTER_CAPS.max) {
172
+ throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id (manual runs at most 5 specialists)");
173
+ }
174
+ if (router === "off" && specialists.length > 0) {
175
+ throw usageError("--router off cannot be combined with --specialists");
176
+ }
177
+ return {
178
+ base: positional[0],
179
+ head: positional[1],
180
+ router,
181
+ specialists,
182
+ format,
183
+ wait,
184
+ timeoutMs,
185
+ context,
186
+ contextFiles,
187
+ idempotencyKey,
188
+ webhookUrl,
189
+ thread,
190
+ };
191
+ }
12
192
  export function resolveReviewBase(explicit, cwd = process.cwd()) {
13
193
  if (explicit)
14
194
  return explicit;
@@ -16,25 +196,9 @@ export function resolveReviewBase(explicit, cwd = process.cwd()) {
16
196
  return discoverTrunk(cwd);
17
197
  }
18
198
  catch {
19
- throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]");
199
+ throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]", REVIEW_EXIT.usage, "usage");
20
200
  }
21
201
  }
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
202
  export function reviewStatusHint(jobId) {
39
203
  return `Check: mergestorm status ${jobId}`;
40
204
  }
@@ -45,149 +209,224 @@ export function withReviewJobRecovery(message, jobId) {
45
209
  return message;
46
210
  return `${message.replace(/\s+$/, "")}\n${hint}`;
47
211
  }
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
212
  function asPollCommandError(err, jobId) {
66
213
  if (err instanceof CommandError) {
67
- return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code);
214
+ return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code, err.retryAfterSeconds !== undefined
215
+ ? { retryAfterSeconds: err.retryAfterSeconds }
216
+ : undefined);
68
217
  }
69
218
  const detail = err instanceof Error ? err.message : String(err);
70
219
  return new CommandError(withReviewJobRecovery(`Poll failed: ${detail}`, jobId));
71
220
  }
72
221
  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;
222
+ const parsed = parseReviewArgs(args);
223
+ const base = resolveReviewBase(parsed.base);
224
+ const head = parsed.head || "HEAD";
225
+ const json = parsed.format === "json";
226
+ const interactive = Boolean(opts.interactive && !json);
227
+ const fallback = {
228
+ baseLabel: base,
229
+ headLabel: head,
230
+ routerMode: parsed.router ?? null,
231
+ specialistsRequested: parsed.specialists,
232
+ };
233
+ let jsonPrinted = false;
234
+ let webhookSecret = null;
235
+ const printJson = (row, extra = {}) => {
236
+ const rowWithSecret = {
237
+ ...(row ?? {}),
238
+ ...(webhookSecret ? { webhook_secret: webhookSecret } : {}),
239
+ };
240
+ console.log(JSON.stringify(toReviewJobEnvelope(rowWithSecret, { ...fallback, ...extra })));
241
+ jsonPrinted = true;
242
+ };
243
+ const progress = (message) => {
244
+ if (json)
245
+ console.error(message);
246
+ else
247
+ console.log(message);
248
+ };
120
249
  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 });
250
+ const cfg = await loadConfig();
251
+ const meBefore = interactive ? await getMe(cfg) : null;
252
+ progress(`Collecting diff ${base}...${head} …`);
253
+ const input = await collectReviewInput(base, head);
254
+ if (!input) {
255
+ if (json)
256
+ printJson(null, { status: "no_changes" });
257
+ else
258
+ console.log("No changes to review.");
259
+ return;
260
+ }
261
+ if (parsed.thread)
262
+ input.thread = parsed.thread;
263
+ fallback.threadSlug = input.thread;
264
+ const extras = await loadReviewContext({
265
+ context: parsed.context,
266
+ contextFiles: parsed.contextFiles,
267
+ });
268
+ progress(`Submitting review (${Buffer.byteLength(input.diff, "utf8")} byte diff, ${input.files.length} file(s))…`);
269
+ if (parsed.router) {
270
+ progress(`Fleet router=${parsed.router}${parsed.specialists.length ? ` specialists=${parsed.specialists.join(",")}` : ""}`);
271
+ }
272
+ let submitted;
273
+ try {
274
+ submitted = await submitReview(cfg, input, {
275
+ routerMode: parsed.router,
276
+ specialists: parsed.specialists,
277
+ context: extras.context,
278
+ contextFiles: extras.contextFiles,
279
+ idempotencyKey: parsed.idempotencyKey,
280
+ webhookUrl: parsed.webhookUrl,
281
+ signal: opts.signal,
282
+ });
283
+ }
284
+ catch (err) {
285
+ if (!isTransientReviewPollError(err))
286
+ throw err;
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ if (json)
289
+ printJson(null, { status: "in_progress", error: message });
290
+ throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
291
+ }
292
+ const jobId = submitted.row.job_id ?? submitted.row.id ?? null;
293
+ fallback.jobId = jobId;
294
+ webhookSecret = submitted.row.webhook_secret ?? null;
295
+ if (submitted.status === 402) {
296
+ const message = "Review limit reached for this period. Upgrade at https://mergestorm.ai/billing";
297
+ if (json)
298
+ printJson(submitted.row, { status: "quota_exceeded", error: message });
299
+ throw new CommandError(message, REVIEW_EXIT.quota, "review_quota");
300
+ }
301
+ if (submitted.status === 429) {
302
+ const retryAfterSeconds = submitted.retryAfterSeconds;
303
+ const message = rateLimitedMessage(retryAfterSeconds);
304
+ if (json) {
305
+ printJson(submitted.row, {
306
+ status: "rate_limited",
307
+ error: message,
308
+ retryAfterSeconds,
309
+ });
126
310
  }
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);
311
+ throw new CommandError(message, REVIEW_EXIT.rate_limited, "rate_limited", {
312
+ retryAfterSeconds,
313
+ });
314
+ }
315
+ if (submitted.status !== 202 && submitted.status !== 200) {
316
+ const message = `Failed: ${JSON.stringify(submitted.row, null, 2)}`;
317
+ if (json)
318
+ printJson(submitted.row, { status: "failed", error: message });
319
+ throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
320
+ }
321
+ if (!jobId) {
322
+ const message = "Review API returned no job_id";
323
+ if (json)
324
+ printJson(submitted.row, { status: "failed", error: message });
325
+ throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
326
+ }
327
+ if (!parsed.wait) {
328
+ if (json)
329
+ printJson(submitted.row, { jobId });
330
+ else
331
+ console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).`);
332
+ return;
333
+ }
334
+ if (!json) {
335
+ const waitHint = interactive
336
+ ? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
337
+ : " Waiting…";
338
+ console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).${waitHint}`);
339
+ }
340
+ let row;
341
+ try {
342
+ row = await pollReview(cfg, jobId, {
343
+ timeoutMs: parsed.timeoutMs,
344
+ intervalMs: opts.pollIntervalMs,
345
+ signal: opts.signal,
346
+ propagateTimeout: interactive,
347
+ onTick(kind) {
348
+ if (json)
349
+ console.error(kind === "retry" ? "?" : ".");
350
+ else
351
+ process.stdout.write(kind === "retry" ? "?" : ".");
352
+ },
353
+ });
354
+ }
355
+ catch (err) {
356
+ if (opts.interactive && err instanceof Error && err.name === "AbortError") {
357
+ throw new DetachedError(jobId);
142
358
  }
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;
359
+ if (interactive && isCommandErrorCode(err, "api_timeout")) {
360
+ throw new DetachedError(jobId);
361
+ }
362
+ if (err instanceof ReviewPollTimeoutError) {
363
+ const message = withReviewJobRecovery("Timed out waiting for review.", jobId);
364
+ if (json) {
365
+ printJson(err.lastRow, {
366
+ jobId,
367
+ status: err.lastRow.status ?? "in_progress",
368
+ error: "Timed out waiting for review.",
369
+ });
150
370
  }
151
- throw new CommandError(withReviewJobRecovery(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, job.job_id));
371
+ throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
152
372
  }
153
- transientFailures = 0;
154
- const row = poll.body;
155
- if (row.status === "queued" || row.status === "in_progress") {
156
- process.stdout.write(".");
157
- continue;
373
+ if (isCommandErrorCode(err, "rate_limited")) {
374
+ if (json) {
375
+ printJson(null, {
376
+ status: "rate_limited",
377
+ error: err.message,
378
+ retryAfterSeconds: err.retryAfterSeconds,
379
+ });
380
+ }
381
+ throw asPollCommandError(err, jobId);
158
382
  }
383
+ throw asPollCommandError(err, jobId);
384
+ }
385
+ if (!json)
159
386
  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
- }
387
+ if (row.status === "failed" || row.status === "quota_exceeded") {
388
+ const message = withReviewJobRecovery(`Review ${row.status}: ${row.error ?? ""}`, jobId);
389
+ if (json)
390
+ printJson(row, { jobId });
391
+ throw new CommandError(message, row.status === "quota_exceeded" ? REVIEW_EXIT.quota : REVIEW_EXIT.failed, row.status === "quota_exceeded" ? "review_quota" : "review_failed");
392
+ }
393
+ if (json) {
394
+ printJson(row, { jobId });
395
+ return;
396
+ }
397
+ console.log(`\nVerdict: ${ansi.bold(row.verdict ?? "comment")}`);
398
+ const ran = row.specialists_run ?? row.findings?.specialists_run ?? [];
399
+ if (ran.length > 0)
400
+ console.log(ansi.dim(`Specialists: ${ran.join(", ")}`));
401
+ console.log(row.summary ?? "");
402
+ renderFindings(row.findings, {
403
+ emptyMessage: true,
404
+ });
405
+ if (interactive && meBefore) {
406
+ const meAfter = await getMe(cfg);
407
+ if (meAfter) {
408
+ const beforeLeft = meBefore.usage.standard.remaining ?? 0;
409
+ const afterLeft = meAfter.usage.standard.remaining ?? 0;
410
+ const used = Math.max(0, beforeLeft - afterLeft);
411
+ if (used > 0) {
412
+ console.log(ansi.dim(`\n${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
175
413
  }
176
414
  }
177
- return;
178
415
  }
179
416
  }
180
417
  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);
418
+ if (err instanceof DetachedError)
419
+ throw err;
420
+ if (json &&
421
+ !jsonPrinted &&
422
+ !(err instanceof Error && err.name === "AbortError")) {
423
+ const message = err instanceof Error ? err.message : String(err);
424
+ printJson(null, { status: "failed", error: message });
186
425
  }
187
- if (err instanceof CommandError) {
188
- throw new CommandError(withReviewJobRecovery(err.message, job.job_id), err.exitCode, err.code);
426
+ if (err instanceof CommandError &&
427
+ (err.code === "auth_invalid" || err.code === "missing_api_key")) {
428
+ throw new CommandError(err.message, REVIEW_EXIT.auth, err.code);
189
429
  }
190
430
  throw err;
191
431
  }
192
- throw new CommandError(withReviewJobRecovery("Timed out waiting for review.", job.job_id));
193
432
  }
@@ -1,10 +1,26 @@
1
- import { adoptStack } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, listStacks, type StackDto } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { createPr, findOpenPrNumber } from "../gh.js";
4
4
  import { parseGithubOriginRepo } from "../git.js";
5
- import { branchExists, commitsAheadOf, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
5
+ import { branchExists, commitsAheadOf, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
6
6
  import { buildPrBodyFromCommit } from "../pr-body.js";
7
7
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
8
+ /** A registered stack layer that would become the parent of a new layer. */
9
+ export type RegisteredParentHit = {
10
+ stackId: string;
11
+ owner: string;
12
+ repo: string;
13
+ branch: string;
14
+ prNumber: number;
15
+ position: number;
16
+ };
17
+ /** Find a registered stack layer whose branch matches `parentBranch`. */
18
+ export declare function findRegisteredParent(stacks: StackDto[], parentBranch: string, owner?: string, repo?: string): RegisteredParentHit | null;
19
+ /**
20
+ * Refuse silently growing an already-registered stack.
21
+ * Local unsubmitted parents are fine; only API-listed layers trip this.
22
+ */
23
+ export declare function assertMayParentOntoRegistered(parentBranch: string, stacks: StackDto[], extend: boolean, verb: "create" | "submit", owner?: string, repo?: string): RegisteredParentHit | null;
8
24
  export declare function requireStackId(raw: string | undefined, usage: string): string;
9
25
  /** Parse `owner/repo#12` or `owner/repo 12`. */
10
26
  export declare function parseAdoptTarget(args: string[]): {
@@ -17,9 +33,22 @@ export declare function parseStackCreateArgs(args: string[]): {
17
33
  name?: string;
18
34
  onto?: string;
19
35
  trunk?: string;
36
+ extend: boolean;
37
+ asJson: boolean;
38
+ };
39
+ /** Parse `stack submit` argv. Exported for tests. */
40
+ export declare function parseStackSubmitArgs(args: string[]): {
41
+ extend: boolean;
20
42
  asJson: boolean;
21
43
  };
22
44
  export declare function parseStackResetArgs(args: string[]): void;
45
+ /** Injectable seams for unit tests. Production callers omit deps. */
46
+ export type StackCreateDeps = {
47
+ cwd?: string;
48
+ listStacks?: typeof listStacks;
49
+ loadConfig?: typeof loadConfig;
50
+ parseGithubOriginRepo?: typeof parseGithubOriginRepo;
51
+ };
23
52
  /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
24
53
  export type StackSubmitDeps = {
25
54
  cwd?: string;
@@ -29,6 +58,7 @@ export type StackSubmitDeps = {
29
58
  parseGithubOriginRepo?: typeof parseGithubOriginRepo;
30
59
  requireGh?: (cwd?: string) => void;
31
60
  branchExists?: typeof branchExists;
61
+ fetchRemoteBranch?: typeof fetchRemoteBranch;
32
62
  pushBranch?: typeof pushBranch;
33
63
  findOpenPrNumber?: typeof findOpenPrNumber;
34
64
  commitsAheadOf?: typeof commitsAheadOf;
@@ -37,7 +67,9 @@ export type StackSubmitDeps = {
37
67
  buildPrBodyFromCommit?: typeof buildPrBodyFromCommit;
38
68
  createPr?: typeof createPr;
39
69
  loadConfig?: typeof loadConfig;
70
+ listStacks?: typeof listStacks;
40
71
  adoptStack?: typeof adoptStack;
72
+ ensureUpperPark?: typeof ensureUpperPark;
41
73
  };
42
74
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
43
75
  export declare function cmdStack(args: string[]): Promise<void>;