mergestorm 0.3.16 → 0.3.18

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.
package/README.md CHANGED
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▟██▛ mergestorm v0.3.16 │
48
+ │ ▟██▛ mergestorm v0.3.18 │
49
49
  │ ▜▙ ● msk_live_… · maelstrom │
50
50
  │ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
51
51
  │ │
52
52
  │ review a diff · usage for tabs · /help for all commands │
53
- │ New in v0.3.16: verify findings, small diff, public dismiss on skip │
53
+ │ New in v0.3.18: pr --wait fences a stale same-head pass │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
package/dist/api.d.ts CHANGED
@@ -46,6 +46,10 @@ export type MeResponse = {
46
46
  limit: number | null;
47
47
  remaining: number | null;
48
48
  };
49
+ /** Non-expiring credits spent after the monthly standard pool. */
50
+ bonus?: {
51
+ remaining: number;
52
+ };
49
53
  };
50
54
  };
51
55
  export type JobListItem = {
@@ -122,13 +126,33 @@ export type PrVortexReview = {
122
126
  verdict: string | null;
123
127
  summary?: string | null;
124
128
  head_sha: string | null;
129
+ /** Attempt number on this head (#2027). Absent only from servers older than the pass column. */
130
+ pass?: number;
125
131
  review_count: number;
126
132
  skip_reason: string | null;
127
133
  reviewed_at: string | null;
128
134
  finding_count: number;
129
135
  findings: PrReviewFindings | null;
130
136
  patch_policy: unknown | null;
137
+ /**
138
+ * MS-01: True when the returned row is a resting completion but a newer
139
+ * attempt is in flight (active lease). The CLI must continue polling.
140
+ */
141
+ newer_pass_pending?: boolean;
142
+ };
143
+ /**
144
+ * Pass selection on the DB-only PR review read (#2027). `pass` names one row;
145
+ * `afterPass` asks for a later pass (`pass > afterPass`) on the SHA filter.
146
+ */
147
+ export type PrReviewPassSelector = {
148
+ pass?: number;
149
+ afterPass?: number;
131
150
  };
151
+ export declare function isPositiveInteger(value: unknown): value is number;
152
+ /** Append pass selection and SHA filter to a PR review query. */
153
+ export declare function applyPrReviewQuery(query: URLSearchParams, opts: PrReviewPassSelector & {
154
+ afterSha?: string;
155
+ }): void;
132
156
  /**
133
157
  * Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
134
158
  * The connected flags are read-only there and deliberately absent here.
@@ -172,7 +196,8 @@ export declare function getReview(jobId: string, cfg?: Config, opts?: {
172
196
  export declare function getPrVortexReview(owner: string, repo: string, prNumber: number, cfg?: Config, opts?: {
173
197
  signal?: AbortSignal;
174
198
  timeoutMs?: number;
175
- }): Promise<PrVortexReview>;
199
+ afterSha?: string;
200
+ } & PrReviewPassSelector): Promise<PrVortexReview>;
176
201
  /** List stacks (Bearer GET /api/v1/stacks). */
177
202
  export declare function listStacks(cfg?: Config, opts?: {
178
203
  timeoutMs?: number;
package/dist/api.js CHANGED
@@ -155,6 +155,18 @@ export async function devicePost(base, route, json, opts = {}) {
155
155
  }
156
156
  return { status: res.status, body: parsed };
157
157
  }
158
+ export function isPositiveInteger(value) {
159
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
160
+ }
161
+ /** Append pass selection and SHA filter to a PR review query. */
162
+ export function applyPrReviewQuery(query, opts) {
163
+ if (opts.afterSha)
164
+ query.set("after_sha", opts.afterSha);
165
+ if (opts.pass !== undefined)
166
+ query.set("pass", String(opts.pass));
167
+ if (opts.afterPass !== undefined)
168
+ query.set("after_pass", String(opts.afterPass));
169
+ }
158
170
  /**
159
171
  * Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
160
172
  * The connected flags are read-only there and deliberately absent here.
@@ -283,6 +295,7 @@ export async function getPrVortexReview(owner, repo, prNumber, cfg, opts) {
283
295
  repo: cleanRepo,
284
296
  pr_number: String(prNumber),
285
297
  });
298
+ applyPrReviewQuery(query, opts ?? {});
286
299
  const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/stacks/pr-review?${query.toString()}`, { signal: opts?.signal, timeoutMs: opts?.timeoutMs });
287
300
  if (status === 404) {
288
301
  throw new CommandError(`Vortex review not found: ${cleanOwner}/${cleanRepo}#${prNumber}`, REVIEW_EXIT.failed, "not_found");
@@ -353,7 +366,7 @@ export function stackPolicyBody(policy) {
353
366
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
354
367
  export async function adoptStack(owner, repo, prNumber, cfg, policy) {
355
368
  const resolved = cfg ?? (await loadConfig());
356
- const { status, body } = await apiFetch(resolved, "/api/v1/stacks/adopt", {
369
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, "/api/v1/stacks/adopt", {
357
370
  method: "POST",
358
371
  json: {
359
372
  owner,
@@ -365,6 +378,9 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
365
378
  if (status === 404) {
366
379
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
367
380
  }
381
+ if (status === 429) {
382
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
383
+ }
368
384
  if (status !== 200) {
369
385
  throw new CommandError(`Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`);
370
386
  }
@@ -22,6 +22,10 @@ export declare const BEARER_SETTINGS: readonly [{
22
22
  readonly key: "cyclone_review_unit_land_prs_enabled";
23
23
  readonly flag: "--cyclone-review-unit-land";
24
24
  readonly label: "Cyclone Review Unit land PRs";
25
+ }, {
26
+ readonly key: "cyclone_skip_ci_enabled";
27
+ readonly flag: "--cyclone-skip-ci";
28
+ readonly label: "Skip CI on Cyclone commits";
25
29
  }, {
26
30
  readonly key: "vortex_seam_specialist_enabled";
27
31
  readonly flag: "--vortex-seam";
@@ -5,6 +5,7 @@ export const BEARER_SETTINGS = [
5
5
  { key: "repo_overview_enabled", flag: "--repo-overview", label: "Repo overview" },
6
6
  { key: "review_unit_land_prs_enabled", flag: "--review-unit-land", label: "Review Unit land PRs" },
7
7
  { key: "cyclone_review_unit_land_prs_enabled", flag: "--cyclone-review-unit-land", label: "Cyclone Review Unit land PRs" },
8
+ { key: "cyclone_skip_ci_enabled", flag: "--cyclone-skip-ci", label: "Skip CI on Cyclone commits" },
8
9
  { key: "vortex_seam_specialist_enabled", flag: "--vortex-seam", label: "Vortex seam specialist" },
9
10
  { key: "auto_land_default", flag: "--auto-land", label: "Auto land (new stacks)" },
10
11
  ];
@@ -2,5 +2,6 @@ export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig,
2
2
  export { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, type BearerSettingsKey, } from "../automation-catalog.js";
3
3
  export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type SettingsResponse, type SettingsWritableKey, type StackDto, type StackPolicyPatch, type ThreadDetail, type ThreadListItem, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, type CommandErrorCode, type CommandErrorExtras, } from "../errors.js";
5
- export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
5
+ export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
7
+ export { discoverTrunk } from "../git-stack.js";
@@ -2,5 +2,6 @@ export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig,
2
2
  export { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, } from "../automation-catalog.js";
3
3
  export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
5
- export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
5
+ export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
7
+ export { discoverTrunk } from "../git-stack.js";
@@ -27,7 +27,7 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
27
27
  /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
28
28
  export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
29
  /** Human labels for the Config tab and the static `settings` printout. */
30
- export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default", string>;
30
+ export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "cyclone_skip_ci_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default", string>;
31
31
  /** Config rows in a stable order: writable toggles, then connected flags. */
32
32
  export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
33
33
  /**
@@ -3,6 +3,7 @@ import { BEARER_SETTINGS_LABELS } from "../automation-catalog.js";
3
3
  import { apiBase, keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { CommandError } from "../errors.js";
5
5
  import { ansi } from "../ui/ansi.js";
6
+ import { humanSummary } from "../ui/human-summary.js";
6
7
  import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
7
8
  import { runTabsBrowser, } from "../ui/tabs.js";
8
9
  import { formatDaysLeft, formatResetLabel } from "../ui/usage.js";
@@ -90,9 +91,10 @@ export function formatJobDetailLines(row) {
90
91
  lines.push(` Credits ${e.credits.standard} standard`);
91
92
  if (e.error)
92
93
  lines.push(ansi.red(` Error ${e.error}`));
93
- if (e.summary) {
94
+ const summary = e.summary ? humanSummary(e.summary) : null;
95
+ if (summary) {
94
96
  lines.push("");
95
- for (const l of e.summary.split("\n"))
97
+ for (const l of summary.split("\n"))
96
98
  lines.push(` ${l}`);
97
99
  }
98
100
  const inline = e.findings?.inline ?? [];
@@ -218,6 +220,7 @@ export async function openTabsBrowser(initial) {
218
220
  plan: me.plan_label_key ?? me.plan_key,
219
221
  used: me.usage.standard.used,
220
222
  limit: me.usage.standard.limit,
223
+ bonusRemaining: me.usage.bonus?.remaining,
221
224
  resetsAt: me.resets_at,
222
225
  jobs: jobs.slice(0, 5).map(toPanelJob),
223
226
  jobsError: jobsError ?? undefined,
@@ -36,6 +36,7 @@ export async function cmdCredits(args) {
36
36
  plan: me.plan_label_key ?? me.plan_key,
37
37
  used: s.used,
38
38
  limit: s.limit,
39
+ bonusRemaining: me.usage.bonus?.remaining,
39
40
  resetsAt: me.resets_at,
40
41
  jobs: recentJobs.map(toPanelJob),
41
42
  columns,
@@ -6,6 +6,10 @@ export type ParsedPrArgs = {
6
6
  format: "json" | "pretty";
7
7
  wait: boolean;
8
8
  afterSha?: string;
9
+ /** Exact pass on the head (#2027). */
10
+ pass?: number;
11
+ /** Only a pass later than this one on the head (#2027). */
12
+ afterPass?: number;
9
13
  timeoutMs: number;
10
14
  };
11
15
  export type PrOptions = {
@@ -3,9 +3,17 @@ import { loadConfig } from "../config.js";
3
3
  import { CommandError, REVIEW_EXIT } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
5
  import { renderFindings } from "../ui/findings.js";
6
+ import { humanSummary } from "../ui/human-summary.js";
6
7
  import { PrReviewPollTimeoutError, REVIEW_POLL_DEFAULT_TIMEOUT_MS, pollPrVortexReview, } from "./review-client.js";
7
8
  import { parseAdoptTarget } from "./stack.js";
8
- const PR_USAGE = "usage: mergestorm pr <owner/repo>#<n> [--json] [--wait] [--after-sha <sha>] [--timeout <s>]";
9
+ const PR_USAGE = "usage: mergestorm pr <owner/repo>#<n> [--json] [--wait] [--after-sha <sha>] [--pass <n>] [--after-pass <n>] [--timeout <s>]";
10
+ function parsePassFlag(flag, value) {
11
+ const parsed = Number(value);
12
+ if (!value?.trim() || !/^[0-9]+$/.test(value.trim()) || !Number.isSafeInteger(parsed) || parsed < 1) {
13
+ throw usageError(`${flag} requires a positive integer`);
14
+ }
15
+ return parsed;
16
+ }
9
17
  function usageError(message = PR_USAGE) {
10
18
  return new CommandError(message, REVIEW_EXIT.usage, "usage");
11
19
  }
@@ -14,6 +22,8 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
14
22
  let format = defaults.format;
15
23
  let wait = false;
16
24
  let afterSha;
25
+ let pass;
26
+ let afterPass;
17
27
  let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
18
28
  for (let i = 0; i < args.length; i += 1) {
19
29
  const arg = args[i];
@@ -42,6 +52,14 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
42
52
  }
43
53
  continue;
44
54
  }
55
+ if (arg === "--pass" || arg.startsWith("--pass=")) {
56
+ pass = parsePassFlag("--pass", arg === "--pass" ? args[++i] : arg.slice("--pass=".length));
57
+ continue;
58
+ }
59
+ if (arg === "--after-pass" || arg.startsWith("--after-pass=")) {
60
+ afterPass = parsePassFlag("--after-pass", arg === "--after-pass" ? args[++i] : arg.slice("--after-pass=".length));
61
+ continue;
62
+ }
45
63
  if (arg === "--timeout" || arg.startsWith("--timeout=")) {
46
64
  const value = arg === "--timeout" ? args[++i] : arg.slice("--timeout=".length);
47
65
  const seconds = Number(value);
@@ -58,6 +76,12 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
58
76
  }
59
77
  if (afterSha && !wait)
60
78
  throw usageError("--after-sha requires --wait");
79
+ if (pass !== undefined && afterPass !== undefined) {
80
+ throw usageError("--pass and --after-pass are exclusive");
81
+ }
82
+ if ((pass !== undefined || afterPass !== undefined) && (!wait || !afterSha)) {
83
+ throw usageError("--pass and --after-pass require --wait --after-sha");
84
+ }
61
85
  if (target.length < 1 || target.length > 2)
62
86
  throw usageError();
63
87
  let parsed;
@@ -67,7 +91,7 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
67
91
  catch {
68
92
  throw usageError();
69
93
  }
70
- return { ...parsed, format, wait, afterSha, timeoutMs };
94
+ return { ...parsed, format, wait, afterSha, pass, afterPass, timeoutMs };
71
95
  }
72
96
  function printPretty(envelope) {
73
97
  console.log(`Status: ${envelope.status}`);
@@ -78,8 +102,9 @@ function printPretty(envelope) {
78
102
  console.log(`Finding count: ${envelope.finding_count}`);
79
103
  if (envelope.skip_reason)
80
104
  console.log(`Skip reason: ${envelope.skip_reason}`);
81
- if (envelope.summary)
82
- console.log(envelope.summary);
105
+ const summary = envelope.summary ? humanSummary(envelope.summary) : null;
106
+ if (summary)
107
+ console.log(summary);
83
108
  renderFindings(envelope.findings, {
84
109
  emptyMessage: envelope.status === "completed",
85
110
  });
@@ -96,6 +121,8 @@ export async function cmdPr(args, opts = {}) {
96
121
  timeoutMs: parsed.timeoutMs,
97
122
  intervalMs: opts.pollIntervalMs,
98
123
  afterSha: parsed.afterSha,
124
+ pass: parsed.pass,
125
+ afterPass: parsed.afterPass,
99
126
  signal: opts.signal,
100
127
  ...opts.poll,
101
128
  onTick(kind) {
@@ -116,7 +143,7 @@ export async function cmdPr(args, opts = {}) {
116
143
  console.log("");
117
144
  }
118
145
  else {
119
- envelope = await getPrVortexReview(parsed.owner, parsed.repo, parsed.prNumber, cfg, { signal: opts.signal });
146
+ envelope = await getPrVortexReview(parsed.owner, parsed.repo, parsed.prNumber, cfg, { signal: opts.signal, pass: parsed.pass, afterPass: parsed.afterPass });
120
147
  }
121
148
  if (parsed.format === "json") {
122
149
  console.log(JSON.stringify(envelope, null, opts.mode === "shell" ? 2 : undefined));
@@ -52,6 +52,7 @@ export const COMMAND_REGISTRY = [
52
52
  " [--context text] [--context-file path] [--thread slug]",
53
53
  " [--idempotency-key k] [--webhook-url https://…]",
54
54
  " HTTP 429 exits 7 (rate_limited) with retry_after_seconds",
55
+ " Uploads over 1 MB / 1_000_000 are rejected with HTTP 413 and do not create a job",
55
56
  ],
56
57
  async run(args, ctx) {
57
58
  if (ctx.mode === "shell") {
@@ -66,7 +67,8 @@ export const COMMAND_REGISTRY = [
66
67
  summary: "Latest Vortex review for a GitHub PR",
67
68
  usage: [
68
69
  "mergestorm pr <owner/repo>#<n> Fetch the latest Vortex PR review",
69
- " [--json] [--wait] [--after-sha <sha>] [--timeout <s>]",
70
+ " [--json] [--wait] [--after-sha <sha>] [--pass <n>] [--after-pass <n>] [--timeout <s>]",
71
+ " --pass reads one attempt on the head; --after-pass waits for a later one (#2027)",
70
72
  " Also accepts: mergestorm pr <owner/repo> <n>",
71
73
  ],
72
74
  async run(args, ctx) {
@@ -163,8 +165,8 @@ export const COMMAND_REGISTRY = [
163
165
  "mergestorm settings [--json] Read the automation toggles (Config tab on a TTY)",
164
166
  " [--auto-review on|off] [--auto-patch on|off] [--vortex-thinking on|off]",
165
167
  " [--repo-overview on|off] [--review-unit-land on|off]",
166
- " [--cyclone-review-unit-land on|off] [--vortex-seam on|off]",
167
- " [--auto-land on|off] Auto land default for stacks opened after this",
168
+ " [--cyclone-review-unit-land on|off] [--cyclone-skip-ci on|off]",
169
+ " [--vortex-seam on|off] [--auto-land on|off] Auto land default for new stacks",
168
170
  " With flags: PATCH those settings and print the stored result",
169
171
  ],
170
172
  async run(args, ctx) {
@@ -1,4 +1,4 @@
1
- import { type PrVortexReview } from "../api.js";
1
+ import { type PrReviewPassSelector, type PrVortexReview } from "../api.js";
2
2
  import type { Config } from "../config.js";
3
3
  import type { ReviewJobRow } from "../ui/job-envelope.js";
4
4
  export declare const REVIEW_POLL_INTERVAL_MS = 2000;
@@ -10,6 +10,8 @@ export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
10
10
  export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
11
11
  /** Same thread slug contract as POST /api/v1/reviews. */
12
12
  export declare const REVIEW_THREAD_SLUG_RE: RegExp;
13
+ /** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
14
+ export declare const MAX_REVIEW_DIFF_BYTES = 1000000;
13
15
  export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
14
16
  export declare const MAX_CONTEXT_FILES = 10;
15
17
  export declare const MAX_CONTEXT_FILE_CHARS = 16000;
@@ -52,7 +54,7 @@ export type LoadReviewContextOptions = {
52
54
  contextFiles: string[];
53
55
  cwd?: string;
54
56
  readStdin?: () => Promise<string>;
55
- /** Reject contextFiles that resolve outside this directory (agent-driven callers). */
57
+ /** Bound contextFiles to this directory; defaults to the canonical repo root. */
56
58
  sandboxCwd?: string;
57
59
  };
58
60
  /** Load --context / --context-file and enforce the API caps client-side. */
@@ -62,6 +64,11 @@ export declare function submitReview(cfg: Config, input: ReviewInput, opts?: Sub
62
64
  row: ReviewJobRow;
63
65
  retryAfterSeconds?: number;
64
66
  }>;
67
+ /**
68
+ * Human copy for a failed POST /api/v1/reviews.
69
+ * nginx 413 is HTML, so `submitReview` stores `{}` and used to print `Failed: {}`.
70
+ */
71
+ export declare function formatReviewSubmitError(status: number, body: unknown): string;
65
72
  /** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
66
73
  export declare function transientRetryWaitMs(input: {
67
74
  retryAfterSeconds?: number;
@@ -98,7 +105,7 @@ export type PollReviewOptions = {
98
105
  /** Test seam for backoff jitter; production uses `Math.random`. */
99
106
  random?: () => number;
100
107
  };
101
- export type PollPrVortexReviewOptions = PollReviewOptions & {
108
+ export type PollPrVortexReviewOptions = PollReviewOptions & PrReviewPassSelector & {
102
109
  afterSha?: string;
103
110
  };
104
111
  /** Poll the DB-only PR review endpoint until the requested pass is resting. */
@@ -1,6 +1,6 @@
1
1
  import { readFile, realpath } from "node:fs/promises";
2
2
  import { join, relative, resolve, sep } from "node:path";
3
- import { apiFetch } from "../api.js";
3
+ import { apiFetch, applyPrReviewQuery, isPositiveInteger, } from "../api.js";
4
4
  import { CommandError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage } from "../errors.js";
5
5
  import { changedNamesInSandbox, collectChangedFiles, git, parseGithubOriginRepo, repoRoot, } from "../git.js";
6
6
  export const REVIEW_POLL_INTERVAL_MS = 2_000;
@@ -12,6 +12,8 @@ export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
12
12
  export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
13
13
  /** Same thread slug contract as POST /api/v1/reviews. */
14
14
  export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
15
+ /** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
16
+ export const MAX_REVIEW_DIFF_BYTES = 1_000_000;
15
17
  export const MAX_CONTEXT_TEXT_CHARS = 60_000;
16
18
  export const MAX_CONTEXT_FILES = 10;
17
19
  export const MAX_CONTEXT_FILE_CHARS = 16_000;
@@ -25,9 +27,11 @@ export async function collectReviewInput(base, head, cwd = process.cwd(), sandbo
25
27
  diff = "";
26
28
  }
27
29
  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);
30
+ const canonicalCwd = await realpath(cwd);
31
+ const root = repoRoot(canonicalCwd);
32
+ const specs = names.map((name) => `:(literal)${relative(canonicalCwd, join(root, name))}`);
33
+ // Collection must not execute configured diff or text conversion helpers.
34
+ diff = git(["diff", "--no-ext-diff", "--no-textconv", `${base}...${head}`, "--", ...specs], cwd);
31
35
  }
32
36
  }
33
37
  catch (err) {
@@ -83,26 +87,28 @@ export async function loadReviewContext(opts) {
83
87
  let total = 0;
84
88
  for (const rawPath of opts.contextFiles) {
85
89
  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
- }
90
+ const boundaryLabel = opts.sandboxCwd ? "working directory" : "repo root";
91
+ let targetReal;
92
+ let sandboxReal;
93
+ try {
94
+ sandboxReal = opts.sandboxCwd ? await realpath(resolve(opts.sandboxCwd)) : repoRoot(cwd);
95
+ }
96
+ catch {
97
+ throw contextUsage(`--context-file ${rawPath} must be inside the ${boundaryLabel}`);
98
+ }
99
+ try {
100
+ targetReal = await realpath(abs);
101
+ }
102
+ catch (err) {
103
+ const detail = err instanceof Error ? err.message : String(err);
104
+ throw contextUsage(`could not read --context-file ${rawPath}: ${detail}`);
105
+ }
106
+ if (targetReal !== sandboxReal && !targetReal.startsWith(`${sandboxReal}${sep}`)) {
107
+ throw contextUsage(`--context-file ${rawPath} must be inside the ${boundaryLabel}`);
102
108
  }
103
109
  let content;
104
110
  try {
105
- content = await readFile(abs, "utf8");
111
+ content = await readFile(targetReal, "utf8");
106
112
  }
107
113
  catch (err) {
108
114
  const detail = err instanceof Error ? err.message : String(err);
@@ -148,6 +154,39 @@ export async function submitReview(cfg, input, opts = {}) {
148
154
  ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
149
155
  };
150
156
  }
157
+ function reviewSubmitErrorText(body) {
158
+ if (body && typeof body === "object") {
159
+ const rec = body;
160
+ const message = typeof rec.message === "string" ? rec.message.trim() : "";
161
+ const error = typeof rec.error === "string" ? rec.error.trim() : "";
162
+ if (message)
163
+ return message;
164
+ if (error)
165
+ return error;
166
+ }
167
+ if (typeof body === "string") {
168
+ const trimmed = body.trim();
169
+ if (trimmed && !trimmed.startsWith("<"))
170
+ return trimmed.slice(0, 240);
171
+ }
172
+ return "";
173
+ }
174
+ /**
175
+ * Human copy for a failed POST /api/v1/reviews.
176
+ * nginx 413 is HTML, so `submitReview` stores `{}` and used to print `Failed: {}`.
177
+ */
178
+ export function formatReviewSubmitError(status, body) {
179
+ const detail = reviewSubmitErrorText(body);
180
+ if (status === 413) {
181
+ if (detail)
182
+ return `${detail} No review job was created and no credits were charged.`;
183
+ return ("Upload rejected (HTTP 413). nginx stopped the body before a review job was created, " +
184
+ "so no credits were charged. Max diff size is 1 MB / 1_000_000 bytes.");
185
+ }
186
+ if (detail)
187
+ return `Failed (HTTP ${status}): ${detail}`;
188
+ return `Failed (HTTP ${status}). The API did not return a JSON error body.`;
189
+ }
151
190
  /** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
152
191
  export function transientRetryWaitMs(input) {
153
192
  const exp = REVIEW_POLL_BACKOFF_MS * 2 ** Math.max(0, input.failureCount - 1);
@@ -242,8 +281,10 @@ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {})
242
281
  repo,
243
282
  pr_number: String(prNumber),
244
283
  });
245
- if (opts.afterSha)
246
- query.set("after_sha", opts.afterSha);
284
+ // #2027: the pass selector is fixed for the whole wait. after_pass stays
285
+ // what the caller handed in on every poll and every retry; it is never
286
+ // replaced by the pass of an in-progress envelope seen along the way.
287
+ applyPrReviewQuery(query, opts);
247
288
  let transientFailures = 0;
248
289
  let lastEnvelope = null;
249
290
  const sleepBeforeRetry = async (retryAfterSeconds) => {
@@ -310,10 +351,23 @@ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {})
310
351
  if (!poll.body || typeof poll.body !== "object") {
311
352
  throw new CommandError("Poll failed: invalid PR review response", REVIEW_EXIT.failed, "review_failed");
312
353
  }
313
- lastEnvelope = poll.body;
354
+ const envelope = poll.body;
355
+ // The server filters by pass; re-check here so a server that ignores the
356
+ // selector can never satisfy the wait with the wrong pass.
357
+ if ((opts.pass !== undefined || opts.afterPass !== undefined) &&
358
+ (!isPositiveInteger(envelope.pass) || (opts.pass !== undefined && envelope.pass !== opts.pass) ||
359
+ (opts.afterPass !== undefined && envelope.pass <= opts.afterPass))) {
360
+ opts.onTick?.("progress");
361
+ await sleepBeforePoll();
362
+ continue;
363
+ }
364
+ lastEnvelope = envelope;
314
365
  const resting = (lastEnvelope.raw_status ?? lastEnvelope.status) !== "in_progress";
315
366
  const matches = !opts.afterSha || shaMatches(lastEnvelope.head_sha, opts.afterSha);
316
- if (resting && matches)
367
+ // MS-01: A resting completion with newer_pass_pending indicates a newer
368
+ // attempt is in flight. Do not satisfy the wait from this stale result.
369
+ const stale = lastEnvelope.newer_pass_pending === true;
370
+ if (resting && matches && !stale)
317
371
  return lastEnvelope;
318
372
  opts.onTick?.("progress");
319
373
  await sleepBeforePoll();
@@ -4,9 +4,10 @@ import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, rateLimit
4
4
  import { discoverTrunk } from "../git-stack.js";
5
5
  import { ansi } from "../ui/ansi.js";
6
6
  import { formatFindingsLines } from "../ui/findings.js";
7
+ import { humanSummary } from "../ui/human-summary.js";
7
8
  import { present } from "../ui/present.js";
8
9
  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
+ 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, formatReviewSubmitError, submitReview, } from "./review-client.js";
10
11
  export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
11
12
  /** Same public specialist allowlist as the API contract and migration 117. */
12
13
  export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
@@ -39,7 +40,8 @@ function normalizeVortexSpecialistIds(value) {
39
40
  const selected = new Set(value.filter((id) => typeof id === "string" && VORTEX_SPECIALIST_IDS.includes(id)));
40
41
  return VORTEX_SPECIALIST_IDS.filter((id) => selected.has(id));
41
42
  }
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
+ 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]\n" +
44
+ "Uploads over 1 MB / 1_000_000 are rejected with HTTP 413 and do not create a job.";
43
45
  function usageError(message = REVIEW_USAGE) {
44
46
  return new CommandError(message, REVIEW_EXIT.usage, "usage");
45
47
  }
@@ -163,6 +165,9 @@ export function parseReviewArgs(args) {
163
165
  thread = slug;
164
166
  continue;
165
167
  }
168
+ if (a === "-h" || a === "--help") {
169
+ throw usageError();
170
+ }
166
171
  if (a.startsWith("-")) {
167
172
  throw usageError(`unknown flag: ${a}`);
168
173
  }
@@ -226,6 +231,10 @@ function asPollCommandError(err, jobId) {
226
231
  return new CommandError(withReviewJobRecovery(`Poll failed: ${detail}`, jobId));
227
232
  }
228
233
  export async function cmdReview(args, opts = {}) {
234
+ if (args.includes("-h") || args.includes("--help")) {
235
+ console.log(REVIEW_USAGE);
236
+ return;
237
+ }
229
238
  const parsed = parseReviewArgs(args);
230
239
  const base = resolveReviewBase(parsed.base);
231
240
  const head = parsed.head || "HEAD";
@@ -255,7 +264,6 @@ export async function cmdReview(args, opts = {}) {
255
264
  };
256
265
  try {
257
266
  const cfg = await loadConfig();
258
- const meBefore = interactive ? await getMe(cfg) : null;
259
267
  progress(`Collecting diff ${base}...${head} …`);
260
268
  const input = await collectReviewInput(base, head);
261
269
  if (!input) {
@@ -320,7 +328,7 @@ export async function cmdReview(args, opts = {}) {
320
328
  });
321
329
  }
322
330
  if (submitted.status !== 202 && submitted.status !== 200) {
323
- const message = `Failed: ${JSON.stringify(submitted.row, null, 2)}`;
331
+ const message = formatReviewSubmitError(submitted.status, submitted.row);
324
332
  if (json)
325
333
  printJson(submitted.row, { status: "failed", error: message });
326
334
  throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
@@ -407,21 +415,24 @@ export async function cmdReview(args, opts = {}) {
407
415
  ];
408
416
  if (ran.length > 0)
409
417
  result.push(ansi.dim(`Specialists: ${ran.join(", ")}`));
410
- if (row.summary)
411
- result.push(row.summary);
418
+ const summary = row.summary ? humanSummary(row.summary) : null;
419
+ if (summary)
420
+ result.push(summary);
412
421
  result.push(...formatFindingsLines(row.findings, {
413
422
  emptyMessage: true,
414
423
  }));
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`));
423
- }
424
+ if (interactive) {
425
+ const me = await getMe(cfg);
426
+ const used = row.credits?.standard;
427
+ const remaining = me?.usage.standard.remaining;
428
+ const credits = [];
429
+ if (used != null) {
430
+ credits.push(`${used} credit${used === 1 ? "" : "s"} used`);
424
431
  }
432
+ if (remaining != null)
433
+ credits.push(`${remaining} remaining`);
434
+ if (credits.length > 0)
435
+ result.push(ansi.dim(credits.join(" · ")));
425
436
  }
426
437
  await present("Review", result);
427
438
  }
@@ -3,7 +3,7 @@ import { type SettingsPatch, type SettingsResponse } from "../api.js";
3
3
  * CLI flag → Bearer settings key. on|off only. The connected flags are
4
4
  * read-only on the API and deliberately have no flag here.
5
5
  */
6
- export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default">;
6
+ export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "cyclone_skip_ci_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default">;
7
7
  export type SettingsArgs = {
8
8
  json: boolean;
9
9
  patch: SettingsPatch;
@@ -519,6 +519,12 @@ export async function cmdStackCreate(args, deps = {}) {
519
519
  }
520
520
  export async function cmdStackSubmit(args, deps = {}) {
521
521
  const { extend, autoLand, autoReview, autoPatch, asJson } = parseStackSubmitArgs(args);
522
+ const progress = (message) => {
523
+ if (asJson)
524
+ console.error(message);
525
+ else
526
+ console.log(message);
527
+ };
522
528
  const cwd = deps.cwd ?? process.cwd();
523
529
  const gitTopLevelFn = deps.gitTopLevel ?? gitTopLevel;
524
530
  const loadStackMetaFn = deps.loadStackMeta ?? loadStackMeta;
@@ -608,7 +614,7 @@ export async function cmdStackSubmit(args, deps = {}) {
608
614
  if (!branchExistsFn(layer.branch, cwd)) {
609
615
  throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
610
616
  }
611
- console.log(ansi.dim(` Pushing ${layer.branch} …`));
617
+ progress(ansi.dim(` Pushing ${layer.branch} …`));
612
618
  try {
613
619
  pushBranchFn(layer.branch, cwd);
614
620
  }
@@ -625,7 +631,7 @@ export async function cmdStackSubmit(args, deps = {}) {
625
631
  const openLayerPr = (branch, base) => {
626
632
  const existing = findOpenPrNumberFn(owner, repo, branch, cwd);
627
633
  if (existing != null) {
628
- console.log(ansi.dim(` PR #${existing} already open for ${branch}`));
634
+ progress(ansi.dim(` PR #${existing} already open for ${branch}`));
629
635
  return { prNumber: existing, created: false };
630
636
  }
631
637
  const aheadRef = isMgParkBranch(base) ? `origin/${base}` : base;
@@ -653,7 +659,7 @@ export async function cmdStackSubmit(args, deps = {}) {
653
659
  }
654
660
  const title = tipCommitSubjectFn(branch, cwd);
655
661
  const body = buildPrBodyFn(tipCommitMessageFn(branch, cwd));
656
- console.log(ansi.dim(` Opening PR ${branch} → ${base} …`));
662
+ progress(ansi.dim(` Opening PR ${branch} → ${base} …`));
657
663
  return {
658
664
  prNumber: createPrFn({
659
665
  owner,
@@ -679,7 +685,7 @@ export async function cmdStackSubmit(args, deps = {}) {
679
685
  let adoptData = null;
680
686
  let stackId = plans.find((plan) => plan.stackId)?.stackId ?? null;
681
687
  const registerPr = async (prNumber) => {
682
- console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${prNumber}) …`));
688
+ progress(ansi.dim(` Registering stack via import (${owner}/${repo}#${prNumber}) …`));
683
689
  const body = await adoptStackFn(owner, repo, prNumber, cfg, policy);
684
690
  if (typeof body !== "object" || body === null) {
685
691
  throw new CommandError("Unexpected response shape from adopt API");
@@ -703,7 +709,7 @@ export async function cmdStackSubmit(args, deps = {}) {
703
709
  if (!stackId) {
704
710
  throw new CommandError("Cannot mint a park freeze before the stack is registered. Open layers 1–2 first, then retry.");
705
711
  }
706
- console.log(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
712
+ progress(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
707
713
  const park = await ensureUpperParkFn(stackId, cfg);
708
714
  for (const plan of pendingPark) {
709
715
  const { prNumber, created } = openLayerPr(plan.branch, park.freezeBranch);
@@ -5,6 +5,7 @@ import { canBrowse, formatJobDetailLines } from "./browse.js";
5
5
  import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, } from "../errors.js";
6
6
  import { ansi } from "../ui/ansi.js";
7
7
  import { renderFindings } from "../ui/findings.js";
8
+ import { humanSummary } from "../ui/human-summary.js";
8
9
  import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
9
10
  import { REVIEW_POLL_DEFAULT_TIMEOUT_MS, ReviewPollTimeoutError, pollReview, } from "./review-client.js";
10
11
  const STATUS_USAGE = "usage: mergestorm status <job_id> [--json] [--wait] [--timeout seconds]";
@@ -71,8 +72,9 @@ function printPretty(row) {
71
72
  if (envelope.credits) {
72
73
  console.log(`Credits: ${envelope.credits.standard} standard`);
73
74
  }
74
- if (envelope.summary)
75
- console.log(envelope.summary);
75
+ const summary = envelope.summary ? humanSummary(envelope.summary) : null;
76
+ if (summary)
77
+ console.log(summary);
76
78
  if (envelope.error)
77
79
  console.log(ansi.red(`Error: ${envelope.error}`));
78
80
  renderFindings(envelope.findings, {
@@ -1,5 +1,5 @@
1
1
  import { getMe } from "../api.js";
2
- import { apiBase, configPath, loadConfig, resolveApiKey, keyDisplay } from "../config.js";
2
+ import { apiBase, configPath, loadConfig, resolveApiKey } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
5
  import { present } from "../ui/present.js";
@@ -16,14 +16,17 @@ export async function cmdWhoami(args, opts = {}) {
16
16
  return;
17
17
  }
18
18
  const me = await getMe(cfg);
19
+ if (!me) {
20
+ throw new CommandError("Live account details unavailable. Could not verify account with /me.");
21
+ }
19
22
  const payload = {
20
- key_prefix: me?.key.prefix ?? keyDisplay(key),
21
- key_name: me?.key.name ?? null,
22
- plan_key: me?.plan_key ?? null,
23
- plan_label_key: me?.plan_label_key ?? null,
23
+ key_prefix: me.key.prefix,
24
+ key_name: me.key.name ?? null,
25
+ plan_key: me.plan_key ?? null,
26
+ plan_label_key: me.plan_label_key ?? null,
24
27
  api_base: apiBase(cfg),
25
28
  config_path: configPath(),
26
- usage: me?.usage ?? null,
29
+ usage: me.usage ?? null,
27
30
  };
28
31
  if (asJson) {
29
32
  const text = JSON.stringify(payload, null, 2);
@@ -42,8 +45,5 @@ export async function cmdWhoami(args, opts = {}) {
42
45
  }
43
46
  lines.push(` API ${payload.api_base}`);
44
47
  lines.push(` Config ${payload.config_path}`);
45
- if (!me) {
46
- lines.push(ansi.dim(" (live account details unavailable — older API or offline)"));
47
- }
48
48
  await present("Account", lines);
49
49
  }
package/dist/git.js CHANGED
@@ -48,7 +48,7 @@ export async function changedNamesInSandbox(base, head, cwd = process.cwd(), san
48
48
  // even though their contents cannot be read from the working tree for upload.
49
49
  // -z disables core.quotePath quoting and NUL-terminates names, so paths with
50
50
  // spaces or non-ASCII characters survive the split verbatim.
51
- const names = git(["diff", "--name-only", "-z", "--diff-filter=ACMRDT", `${base}...${head}`], cwd)
51
+ const names = git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMRDT", `${base}...${head}`], cwd)
52
52
  .split("\0")
53
53
  .filter(Boolean);
54
54
  const kept = [];
@@ -1,3 +1,11 @@
1
+ export type RestackError = {
2
+ kind: "rebase_conflict" | "checkout_failed" | "head_unresolved" | "push_failed";
3
+ detail: string;
4
+ headSha: string;
5
+ attemptedAt: string;
6
+ attempts: number;
7
+ backupRef: string | null;
8
+ };
1
9
  /**
2
10
  * Wire types returned by the stack list/enrich HTTP endpoints.
3
11
  *
@@ -7,8 +15,12 @@
7
15
  export type StackBranchState = "clean" | "needs_restack" | "restacking" | "conflict" | "merged" | "closed";
8
16
  export type StackCiStatus = "success" | "pending" | "failure" | "neutral" | "none" | "unknown";
9
17
  export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" | "failed" | "none" | "unknown";
10
- /** `failed` is the newest Vortex `pr_reviews` row. Work paints Review failed. */
11
- export type StackVortexStatus = "reviewing" | "seam_pending" | "all_clear" | "findings" | "throttled" | "failed";
18
+ /**
19
+ * `failed` is the newest Vortex `pr_reviews` row. Work paints Review failed.
20
+ * `incomplete` is a posted verdict whose head still has unreviewed files
21
+ * (#1947): Work paints Incomplete, never Reviewed or all clear.
22
+ */
23
+ export type StackVortexStatus = "queued" | "skipped" | "reviewing" | "seam_pending" | "all_clear" | "findings" | "throttled" | "failed" | "incomplete";
12
24
  export type StackCycloneStatus = "patching" | "awaiting_fix";
13
25
  export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
14
26
  export type StackAgentName = "vortex" | "cyclone" | "tempest";
@@ -24,6 +36,8 @@ export type StackAgentRun = {
24
36
  status: string;
25
37
  /** Full SHA when known. UI shortens to 7. */
26
38
  sha: string | null;
39
+ pass?: number | null;
40
+ skipReason?: string | null;
27
41
  startedAt?: string | null;
28
42
  finishedAt?: string | null;
29
43
  /**
@@ -38,6 +52,15 @@ export type StackAgentRun = {
38
52
  * Only on resting Vortex `findings` runs.
39
53
  */
40
54
  findingCount?: number | null;
55
+ /**
56
+ * File coverage at the posted head from `vortex_pr_review_state.file_states`
57
+ * (#1947): files reviewed at this head over all changed files. Only on
58
+ * resting Vortex `incomplete` runs, so the chip tooltip can say 21/32.
59
+ */
60
+ coverage?: {
61
+ reviewed: number;
62
+ total: number;
63
+ } | null;
41
64
  };
42
65
  export type StackLayerChecks = {
43
66
  total: number;
@@ -118,6 +141,7 @@ export type StackLayerDto = {
118
141
  */
119
142
  agentRuns?: StackAgentRun[];
120
143
  conflictDetail: string | null;
144
+ restackError?: RestackError | null;
121
145
  lastRestackedSha: string | null;
122
146
  /** GitHub PR mergeability from enrich; null when unknown. */
123
147
  mergeable: boolean | null;
@@ -207,6 +231,7 @@ export type StackUnitDto = {
207
231
  */
208
232
  landPr: StackLayerDto | null;
209
233
  };
234
+ export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
210
235
  export type StackDto = {
211
236
  id: string;
212
237
  owner: string;
@@ -234,6 +259,26 @@ export type StackDto = {
234
259
  * account `auto_patch_enabled` flag; a boolean wins in both directions.
235
260
  */
236
261
  autoPatchOverride?: boolean | null;
262
+ /**
263
+ * Bearer / stack-row user. Cyclone apply follows this user's auto-patch
264
+ * policy and credits for an adopted stack (HOUSE-1), even when
265
+ * `cycloneInstallUserId` is a different Mergestorm account on a shared repo
266
+ * (LOOP-5).
267
+ */
268
+ keyUserId?: string;
269
+ /**
270
+ * Cyclone GitHub App install user for this owner/repo, when resolved. Its
271
+ * installation clones and pushes; it does not decide auto-patch for another
272
+ * user's stack.
273
+ */
274
+ cycloneInstallUserId?: string | null;
275
+ /**
276
+ * `same`: the only Cyclone install user is the key user.
277
+ * `different`: a Cyclone install user is not the key user (or more than one).
278
+ * `none`: no Cyclone install monitors this repo.
279
+ * `lookup_failed`: the comparison could not be loaded. Fail closed.
280
+ */
281
+ cycloneOwnerMatch?: CycloneOwnerMatch;
237
282
  layers: StackLayerDto[];
238
283
  /** Present when a review-unit row exists for this stack. */
239
284
  unit?: StackUnitDto;
package/dist/stack-dto.js CHANGED
@@ -1,7 +1 @@
1
- /**
2
- * Wire types returned by the stack list/enrich HTTP endpoints.
3
- *
4
- * `api/src/stacks.ts#loadStackDtos` is the producer. Keep this file
5
- * dependency-free and byte-for-byte aligned with the canonical wire contract.
6
- */
7
1
  export {};
package/dist/ui/banner.js CHANGED
@@ -84,6 +84,12 @@ const WHATS_NEW = {
84
84
  "0.3.16": [
85
85
  "PR loop: verify each finding, prefer a small diff, post mergestorm-loop: dismiss on skip",
86
86
  ],
87
+ "0.3.17": [
88
+ "nginx 413 names the upload limit instead of Failed: {}",
89
+ ],
90
+ "0.3.18": [
91
+ "pr --wait fences a stale same-head pass",
92
+ ],
87
93
  };
88
94
  function densityFromVariant(variant) {
89
95
  if (variant === "mini" || variant === "nano")
@@ -0,0 +1,2 @@
1
+ /** Remove tool/LLM JSON that is unsafe to show in human summary output. */
2
+ export declare function humanSummary(summary: string): string | null;
@@ -0,0 +1,153 @@
1
+ const TOOL_PAYLOAD_KEYS = new Set([
2
+ "function_call",
3
+ "tool",
4
+ "tool_call",
5
+ "tool_calls",
6
+ ]);
7
+ function parseJsonContainer(value) {
8
+ const trimmed = value.trim();
9
+ if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
10
+ (trimmed.startsWith("[") && trimmed.endsWith("]")))) {
11
+ return undefined;
12
+ }
13
+ try {
14
+ return JSON.parse(trimmed);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function looksLikeToolPayload(value) {
21
+ if (Array.isArray(value))
22
+ return value.some(looksLikeToolPayload);
23
+ if (!value || typeof value !== "object")
24
+ return false;
25
+ const record = value;
26
+ const keys = Object.keys(record);
27
+ if (keys.some((key) => TOOL_PAYLOAD_KEYS.has(key.toLowerCase())))
28
+ return true;
29
+ const lower = new Set(keys.map((key) => key.toLowerCase()));
30
+ if (record.type === "tool_use" && lower.has("name") && lower.has("input"))
31
+ return true;
32
+ if (lower.has("verdict") && lower.has("comments"))
33
+ return true;
34
+ return Object.values(record).some(looksLikeToolPayload);
35
+ }
36
+ function matchingJsonEnd(text, start) {
37
+ const open = text[start];
38
+ if (open !== "{" && open !== "[")
39
+ return null;
40
+ const stack = [open];
41
+ let quoted = false;
42
+ let escaped = false;
43
+ for (let i = start + 1; i < text.length; i += 1) {
44
+ const char = text[i];
45
+ if (quoted) {
46
+ if (escaped)
47
+ escaped = false;
48
+ else if (char === "\\")
49
+ escaped = true;
50
+ else if (char === '"')
51
+ quoted = false;
52
+ continue;
53
+ }
54
+ if (char === '"') {
55
+ quoted = true;
56
+ continue;
57
+ }
58
+ if (char === "{" || char === "[")
59
+ stack.push(char);
60
+ else if (char === "}" || char === "]") {
61
+ const expected = char === "}" ? "{" : "[";
62
+ if (stack.pop() !== expected)
63
+ return null;
64
+ if (stack.length === 0)
65
+ return i + 1;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ function fencedLines(text) {
71
+ const lines = text.split("\n");
72
+ const protectedLines = new Set();
73
+ const openFences = [];
74
+ for (let i = 0; i < lines.length; i += 1) {
75
+ if (/^\s*```[a-z0-9_-]+\s*$/i.test(lines[i])) {
76
+ openFences.push(i);
77
+ }
78
+ else if (/^\s*```\s*$/.test(lines[i])) {
79
+ if (openFences.length > 0) {
80
+ const start = openFences.pop();
81
+ for (let line = start; line <= i; line += 1)
82
+ protectedLines.add(line);
83
+ }
84
+ else {
85
+ openFences.push(i);
86
+ }
87
+ }
88
+ }
89
+ return protectedLines;
90
+ }
91
+ function stripBoundaryPayload(text) {
92
+ let result = text;
93
+ let changed = true;
94
+ while (changed) {
95
+ changed = false;
96
+ const protectedLines = fencedLines(result);
97
+ for (let start = 0; start < result.length; start += 1) {
98
+ if (result[start] !== "{" && result[start] !== "[")
99
+ continue;
100
+ if (protectedLines.has(result.slice(0, start).split("\n").length - 1))
101
+ continue;
102
+ const end = matchingJsonEnd(result, start);
103
+ if (end == null)
104
+ continue;
105
+ const parsed = parseJsonContainer(result.slice(start, end));
106
+ if (parsed !== undefined &&
107
+ looksLikeToolPayload(parsed)) {
108
+ const before = result.slice(0, start);
109
+ const after = result.slice(end);
110
+ result = `${before}${before.endsWith("\n") && after.startsWith("\n") ? after.slice(1) : after}`;
111
+ changed = true;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+ function stripOrphanLines(text) {
119
+ const lines = text.split("\n");
120
+ const keptLines = new Set();
121
+ const protectedLines = fencedLines(text);
122
+ for (const line of protectedLines)
123
+ keptLines.add(line);
124
+ for (let start = 0; start < text.length; start += 1) {
125
+ if (text[start] !== "{" && text[start] !== "[")
126
+ continue;
127
+ const end = matchingJsonEnd(text, start);
128
+ if (end == null || parseJsonContainer(text.slice(start, end)) === undefined)
129
+ continue;
130
+ const i = text.slice(0, start).split("\n").length - 1;
131
+ const lastLine = text.slice(0, end).split("\n").length - 1;
132
+ for (let line = i; line <= lastLine; line += 1) {
133
+ keptLines.add(line);
134
+ if (i !== lastLine)
135
+ protectedLines.add(line);
136
+ }
137
+ }
138
+ return lines
139
+ .filter((line, i) => protectedLines.has(i) ||
140
+ (parseJsonContainer(line) === undefined &&
141
+ (keptLines.has(i) || !/^\s*(?:```|[{}])\s*$/.test(line))))
142
+ .join("\n");
143
+ }
144
+ /** Remove tool/LLM JSON that is unsafe to show in human summary output. */
145
+ export function humanSummary(summary) {
146
+ let result = summary.replace(/\r\n?/g, "\n");
147
+ result = result.replace(/```json[ \t]*\n([\s\S]*?)```/gi, (block, body) => parseJsonContainer(body) === undefined ? block : "");
148
+ result = stripBoundaryPayload(result);
149
+ result = stripOrphanLines(result)
150
+ .replace(/\n{3,}/g, "\n\n")
151
+ .trim();
152
+ return result || null;
153
+ }
@@ -22,8 +22,6 @@ export declare function nextUtcMonthStart(now?: Date): string;
22
22
  export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
23
23
  /** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
24
24
  export declare function formatDaysLeft(iso: string | null | undefined, now?: Date): string;
25
- /** Alias kept for callers that still import `formatResetDate`. */
26
- export declare function formatResetDate(iso: string | null | undefined): string;
27
25
  /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
28
26
  export declare function usageBarWidth(columns: number): number;
29
27
  export type UsagePanelJob = {
@@ -38,6 +36,7 @@ export type UsagePanelInput = {
38
36
  plan: string;
39
37
  used: number;
40
38
  limit: number | null;
39
+ bonusRemaining?: number;
41
40
  resetsAt: string | null | undefined;
42
41
  jobs: UsagePanelJob[];
43
42
  /** When the jobs fetch failed, show this instead of "No review jobs yet." */
package/dist/ui/usage.js CHANGED
@@ -83,10 +83,6 @@ export function formatDaysLeft(iso, now = new Date()) {
83
83
  return `${hours}h left`;
84
84
  return "resets soon";
85
85
  }
86
- /** Alias kept for callers that still import `formatResetDate`. */
87
- export function formatResetDate(iso) {
88
- return formatResetLabel(iso);
89
- }
90
86
  /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
91
87
  export function usageBarWidth(columns) {
92
88
  return Math.max(8, frameWidth(80, columns) - 13);
@@ -112,6 +108,9 @@ export function formatUsagePanel(input) {
112
108
  ` ${identity}`,
113
109
  ` ${bar}`,
114
110
  ` ${formatResetLabel(input.resetsAt)}`,
111
+ ...(input.bonusRemaining == null
112
+ ? []
113
+ : [` Bonus credits: ${input.bonusRemaining} remaining`]),
115
114
  "",
116
115
  ];
117
116
  if (input.jobs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -62,11 +62,11 @@ Default: omit `router` (account setting).
62
62
 
63
63
  ## Submit
64
64
 
65
- Call `review_submit` on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review — never from outside it. Do not paste the diff into context.
65
+ Call `review_submit` with `wait: false` (the default) on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review, never from outside it. Do not paste the diff into context.
66
66
 
67
- Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`. Default wait is 300s; on timeout the tool returns `status: in_progress` and the job id — then call `review_wait`.
67
+ Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`. Submit returns without waiting. Record `job_id` as soon as it returns, then loop `review_wait` with that `job_id` and `timeout_s: 45` until the job finishes. Each call is one 45s slice. On timeout, call `review_wait` again with the same `job_id`. Do not pass 300; hosts drop long MCP calls. Explicit `wait: true` also defaults to one 45s slice.
68
68
 
69
- Use `review_get` or `review_list` to resume a known job.
69
+ Call `review_list` before any resubmit, including when a submit or wait call is interrupted. Resume the existing job with `review_get` or `review_wait`; do not create a second job because a slice died or the submit response was lost.
70
70
 
71
71
  Status handling:
72
72
 
@@ -19,29 +19,35 @@ mg skill install --claude --cursor
19
19
 
20
20
  1. There is an already-open GitHub PR on a Vortex-monitored repo. No PR, wrong skill.
21
21
  2. Auto-patch (Cyclone) must be off for this PR so you are the only patcher. Do not take anyone's word for it: the settings API and the PR's stack are the source of truth, and step 1 of the loop verifies them before anything else runs. Cyclone is off for the PR when its adopted stack has `autoPatchOverride` `false`, or when that override is unset and the account `auto_patch_enabled` is `false`.
22
- 3. `whoami` works on the `mergestorm` MCP server (or `mg whoami --json` exits 0). Otherwise stop and ask for install/login.
22
+ 3. The Bearer key and the Cyclone GitHub App install must be the same Mergestorm account. `stack_list` / `stack_status` expose `keyUserId`, `cycloneInstallUserId`, and `cycloneOwnerMatch`. Cyclone apply always uses the install account, not the key. If those identities differ, your `settings_get` mute reading is about the wrong person.
23
+ 4. `whoami` works on the `mergestorm` MCP server (or `mg whoami --json` exits 0). Otherwise stop and ask for install/login.
23
24
 
24
25
  ## Loop (in order)
25
26
 
26
- 1. Call `settings_get` and read `auto_patch_enabled`.
27
+ 1. Find the PR's stack with `stack_list` (the stack whose `layers` contain this PR's `prNumber` for this owner/repo). If you already have its `stack_id`, `stack_status` is the same payload.
28
+ - If there is no stack and the human asked you to mute Cyclone or continue the loop: call `stack_adopt` with `{ owner, repo, pr_number, auto_patch: false }` (equivalent to `mg stack adopt … --auto-patch off`). Then call `stack_status` with the returned `result.stack.id`, confirm this PR is in the stack and `autoPatchOverride` is `false`. If adoption or verification fails, refuse the loop. Otherwise continue through the ownership check below and the remaining loop steps; do not stop and ask the human to adopt. Do not turn account auto-patch off.
29
+ - Adoption does not fix installer ≠ key. After adoption, the same `cycloneOwnerMatch` refusal below still applies.
30
+ - Read `cycloneOwnerMatch` on that stack. If it is missing, `lookup_failed`, or `different`: refuse the loop. Do not wait, patch, or push. Tell the human the API key and the Cyclone GitHub App install are different Mergestorm accounts (or the comparison could not be loaded). Cyclone still patches as the installer. Do not turn account auto-patch off, and do not set `auto_patch: false` to paper over this.
31
+ - If it is `same` or `none`: continue. `none` means no Cyclone install monitors this repo.
32
+ 2. Call `settings_get` and read `auto_patch_enabled`.
27
33
  - If `settings_get` errors or its result has no `auto_patch_enabled`: refuse the loop. Do not wait, patch, or push; tell the human that Cyclone auto-patch could not be verified.
28
- - If it is `false`: find the PR's stack with `stack_list`. If its `autoPatchOverride` is `true`, refuse unless the human asked you to turn Cyclone off for this PR; if they did, use the adopted-stack and `stack_set { auto_patch: false }` path below. Otherwise continue. You are the only patcher.
29
- - If it is `true`: find the PR's stack with `stack_list` (the stack whose `layers` contain this PR's `prNumber` for this owner/repo) and read its `autoPatchOverride`.
30
- - If that stack's `autoPatchOverride` is `false`: continue. Cyclone is pinned off for this stack.
31
- - If the PR has no stack, or the override is not `false`, and the human asked you to turn Cyclone off for this PR: the PR must be an adopted stack layer first (if it is not, stop and tell the human to adopt it, for example `mg stack adopt <owner/repo>#<n> --auto-patch off`). Then call `stack_set` with `{ stack_id, auto_patch: false }` on that stack, call `stack_status` with that `stack_id`, and confirm `autoPatchOverride` now reads `false`. Leave the account `auto_patch_enabled` unchanged; other PRs keep their own policy.
34
+ - If it is `false`: read `autoPatchOverride` on the stack from step 1. If it is `true`, refuse unless the human asked you to turn Cyclone off for this PR; if they did, use the `stack_set { auto_patch: false }` path below. Otherwise continue. You are the only patcher.
35
+ - If it is `true`: read `autoPatchOverride` on that same stack.
36
+ - If `autoPatchOverride` is `false`: continue. Cyclone is pinned off for this stack.
37
+ - If the override is not `false` and the human asked you to turn Cyclone off for this PR: call `stack_set` with `{ stack_id, auto_patch: false }` on that stack, call `stack_status` with that `stack_id`, and confirm `autoPatchOverride` now reads `false`. Leave the account `auto_patch_enabled` unchanged; other PRs keep their own policy.
32
38
  - Otherwise: refuse the loop. Do not wait, patch, or push; tell the human Cyclone auto-patch is still on for this PR and that `stack_set` with `auto_patch: false` on its stack turns it off for this PR only.
33
39
  - Never set `auto_patch_enabled` with `settings_set` from this skill, in either direction, and never set `auto_patch: true` on a stack.
34
- 2. Call `review_wait_pr` with the PR's owner, repo, pr_number, and `timeout_s` 45 (`review_get_pr` works for a first look at the resting state). A Vortex pass often takes several minutes. If status is `in_progress`, call `review_wait_pr` again with the same args. Do not pass a 300s timeout; hosts drop long MCP calls.
35
- 3. Confirm the envelope's `head_sha` is the commit you have checked out as the PR head (compare its leading 7+ hex characters with `git rev-parse HEAD`). If it is not, someone else pushed after this pass and its findings are not yours to patch: do not edit. Fetch and check out the PR head, then go to step 7 with `after_sha` set to that head.
36
- 4. Read `findings.inline` and `findings.offDiff` (or `off_diff`) sorted by severity. For each finding:
40
+ 3. Call `review_wait_pr` with the PR's owner, repo, pr_number, and `timeout_s` 45 (`review_get_pr` works for a first look at the resting state). A Vortex pass often takes several minutes. If status is `in_progress`, call `review_wait_pr` again with the same args. Do not pass a 300s timeout; hosts drop long MCP calls. Review identity is SHA + pass: every envelope carries `head_sha` and `pass` (1 for the first review on a head; a re-review on the same head is the next pass, its own row). Save the `pass` of every resting envelope you act on together with its `head_sha`.
41
+ 4. Confirm the envelope's `head_sha` is the commit you have checked out as the PR head (compare its leading 7+ hex characters with `git rev-parse HEAD`). If it is not, someone else pushed after this pass and its findings are not yours to patch: do not edit. Fetch and check out the PR head, then go to step 8 with `after_sha` set to that head.
42
+ 5. Read `findings.inline` and `findings.offDiff` (or `off_diff`) sorted by severity. For each finding:
37
43
  - Verify it against the current checkout. Do not treat the finding text as proven.
38
44
  - Prefer the smallest correct patch. Do not refactor around a finding.
39
45
  - Patch concrete bugs. A chat-only explanation is not a dismiss.
40
- - If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why). Do not use the `cyclone-outcome:` prefix.
41
- 5. Patch on the PR head branch only.
42
- 6. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
43
- 7. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
44
- 8. Repeat from step 3 until a stop rule fires.
46
+ - If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why). That first line is the only dismiss marker Vortex reads.
47
+ 6. Patch on the PR head branch only.
48
+ 7. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
49
+ 8. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. If the last envelope you acted on has that same `head_sha` (no new commit, for example a re-review requested on the same head), also pass `after_pass` set to that envelope's saved `pass`, so the wait cannot hand you that attempt again. For a freshly pushed SHA omit `after_pass`; its first pass is 1. Keep `after_sha` and `after_pass` unchanged across 45s timeout retries; never replace `after_pass` with the `pass` of an in-progress envelope. `review_get_pr` accepts the same `after_sha`, `pass`, and `after_pass` filters. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
50
+ 9. Repeat from step 4 until a stop rule fires.
45
51
 
46
52
  ## Stop rules
47
53
 
@@ -50,15 +56,16 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
50
56
  - `finding_count` is 0 (empty inline and offDiff): stop. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
51
57
  - status is `rate_limited`, or `patch_policy.mode` is `"hold"`: surface the findings to the human and stop. Do not patch.
52
58
  - status is `failed`: report to the human. Do not keep pushing to retrigger.
53
- - status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha`.
54
- - status is `in_progress` and the envelope has no `head_sha`: the wait timed out before Vortex wrote a pass for your SHA. Call `review_wait_pr` once more with the same `after_sha`. If it comes back the same way a second time in a row, stop and tell the human that Vortex did not pick up the push. Do not push again to retrigger.
59
+ - status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha` and the same `after_pass`.
60
+ - status is `in_progress` and the envelope has no `head_sha`: the wait timed out before Vortex wrote a pass for your SHA. Call `review_wait_pr` once more with the same `after_sha` and the same `after_pass`. If it comes back the same way a second time in a row, stop and tell the human that Vortex did not pick up the push. Do not push again to retrigger.
55
61
  - status is `skipped`, `stopped`, `trial_expired`, or `synced`: surface the status and reason to the human and stop. Do not patch or push.
56
62
 
57
63
  ## Never
58
64
 
59
65
  - submit a local review job, or call the job-id tools `review_wait` / `review_get` here. Local jobs are `mergestorm-review`'s world: they cost credits and do not fire Vortex on the PR.
60
- - land, merge, enqueue, or touch stack authoring or the merge queue
61
- - change the account `auto_patch_enabled` from this skill, for any reason; Cyclone is muted per stack with `stack_set`, never per account
66
+ - land, merge, enqueue, or touch stack authoring beyond adopting the existing PR, or the merge queue
67
+ - change the account `auto_patch_enabled` from this skill, for any reason; Cyclone is muted per stack with `stack_adopt` or `stack_set`, never per account
68
+ - treat the key user's mute or `auto_patch_enabled` as covering a different Cyclone install user
62
69
  - set `auto_patch: true` on any stack
63
70
  - push any branch other than the PR head
64
71
  - force-push, or rebase and retry a push that was rejected because the remote moved
@@ -75,10 +82,10 @@ Verify auto-patch with the CLI instead of `settings_get`:
75
82
  mg settings --json
76
83
  ```
77
84
 
78
- Read `auto_patch_enabled` from stdout. If the command errors or the result has no `auto_patch_enabled`, refuse the loop and tell the human that Cyclone auto-patch could not be verified. Run `mg stack list --json` and find the stack whose layers contain this PR. If its `autoPatchOverride` is `false`, continue. If it is unset and the account flag is `false`, continue. If it is `true`, or it is unset while the account flag is `true`, refuse unless the human asked you to turn Cyclone off for this PR; only then run `mg stack set <stack-id> --auto-patch off` (adopt first with `mg stack adopt <owner/repo>#<n> --auto-patch off` when the PR has no stack) and re-read with `mg stack list --json`. Do not run `mg settings --auto-patch off`: that changes the account, not this PR.
85
+ Read `auto_patch_enabled` from stdout. If the command errors or the result has no `auto_patch_enabled`, refuse the loop and tell the human that Cyclone auto-patch could not be verified. Run `mg stack list --json` and find the stack whose layers contain this PR. If there is no stack, or its `cycloneOwnerMatch` is missing, `lookup_failed`, or `different`, refuse the loop. Tell the human the API key and the Cyclone install are different accounts (or adopt first, or the lookup failed). Do not run `mg settings --auto-patch off`. If `cycloneOwnerMatch` is `same` or `none` and `autoPatchOverride` is `false`, continue. If it is unset and the account flag is `false`, continue. If it is `true`, or it is unset while the account flag is `true`, refuse unless the human asked you to turn Cyclone off for this PR; only then run `mg stack set <stack-id> --auto-patch off` and re-read with `mg stack list --json`.
79
86
 
80
87
  ```bash
81
- mg pr <owner/repo>#<n> --json --wait --after-sha <sha> --timeout <s>
88
+ mg pr <owner/repo>#<n> --json --wait --after-sha <sha> --after-pass <saved-pass> --timeout <s>
82
89
  ```
83
90
 
84
- Parse stdout as one `mergestorm.pr_review/v1` object. Same fields, same stop rules.
91
+ Parse stdout as one `mergestorm.pr_review/v1` object. Same fields, same stop rules. `--after-pass` follows the same rule as `after_pass` above: only when the last envelope you acted on is on that same `head_sha`; omit it for a freshly pushed SHA. `--pass <n>` reads one exact attempt on the head.