mergestorm 0.3.15 → 0.3.17

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.
@@ -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 = "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";
@@ -38,6 +50,15 @@ export type StackAgentRun = {
38
50
  * Only on resting Vortex `findings` runs.
39
51
  */
40
52
  findingCount?: number | null;
53
+ /**
54
+ * File coverage at the posted head from `vortex_pr_review_state.file_states`
55
+ * (#1947): files reviewed at this head over all changed files. Only on
56
+ * resting Vortex `incomplete` runs, so the chip tooltip can say 21/32.
57
+ */
58
+ coverage?: {
59
+ reviewed: number;
60
+ total: number;
61
+ } | null;
41
62
  };
42
63
  export type StackLayerChecks = {
43
64
  total: number;
@@ -118,6 +139,7 @@ export type StackLayerDto = {
118
139
  */
119
140
  agentRuns?: StackAgentRun[];
120
141
  conflictDetail: string | null;
142
+ restackError?: RestackError | null;
121
143
  lastRestackedSha: string | null;
122
144
  /** GitHub PR mergeability from enrich; null when unknown. */
123
145
  mergeable: boolean | null;
@@ -207,6 +229,7 @@ export type StackUnitDto = {
207
229
  */
208
230
  landPr: StackLayerDto | null;
209
231
  };
232
+ export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
210
233
  export type StackDto = {
211
234
  id: string;
212
235
  owner: string;
@@ -224,6 +247,36 @@ export type StackDto = {
224
247
  * Missing on older payloads is off.
225
248
  */
226
249
  autoEnqueueWhenReady?: boolean;
250
+ /**
251
+ * Per-stack Vortex auto-review override. `null` (or missing) follows the
252
+ * account `auto_review_enabled` flag; a boolean wins in both directions.
253
+ */
254
+ autoReviewOverride?: boolean | null;
255
+ /**
256
+ * Per-stack Cyclone auto-patch override. `null` (or missing) follows the
257
+ * account `auto_patch_enabled` flag; a boolean wins in both directions.
258
+ */
259
+ autoPatchOverride?: boolean | null;
260
+ /**
261
+ * Bearer / stack-row user. Cyclone apply follows this user's auto-patch
262
+ * policy and credits for an adopted stack (HOUSE-1), even when
263
+ * `cycloneInstallUserId` is a different Mergestorm account on a shared repo
264
+ * (LOOP-5).
265
+ */
266
+ keyUserId?: string;
267
+ /**
268
+ * Cyclone GitHub App install user for this owner/repo, when resolved. Its
269
+ * installation clones and pushes; it does not decide auto-patch for another
270
+ * user's stack.
271
+ */
272
+ cycloneInstallUserId?: string | null;
273
+ /**
274
+ * `same`: the only Cyclone install user is the key user.
275
+ * `different`: a Cyclone install user is not the key user (or more than one).
276
+ * `none`: no Cyclone install monitors this repo.
277
+ * `lookup_failed`: the comparison could not be loaded. Fail closed.
278
+ */
279
+ cycloneOwnerMatch?: CycloneOwnerMatch;
227
280
  layers: StackLayerDto[];
228
281
  /** Present when a review-unit row exists for this stack. */
229
282
  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 {};
@@ -5,6 +5,10 @@ export type StackLayerMeta = {
5
5
  };
6
6
  export type LocalStack = {
7
7
  layers: StackLayerMeta[];
8
+ autoEnqueueWhenReady?: boolean;
9
+ /** Per-open override carried from `stack create` to `stack submit`; absent = account flag. */
10
+ autoReviewOverride?: boolean;
11
+ autoPatchOverride?: boolean;
8
12
  };
9
13
  export type StackMeta = {
10
14
  version: 2;
@@ -20,6 +24,14 @@ export declare function loadStackMeta(cwd?: string, stacksRoot?: string): Promis
20
24
  export declare function emptyStackMeta(trunk: string): StackMeta;
21
25
  export declare function activeStack(meta: StackMeta): LocalStack;
22
26
  export declare function activeLayers(meta: StackMeta): StackLayerMeta[];
27
+ export declare function setActiveAutoEnqueue(meta: StackMeta, enabled: boolean | undefined): StackMeta;
28
+ /** Per-open automation flags remembered on the active local stack until submit. */
29
+ export type LocalStackPolicy = {
30
+ autoEnqueueWhenReady?: boolean;
31
+ autoReviewOverride?: boolean;
32
+ autoPatchOverride?: boolean;
33
+ };
34
+ export declare function setActivePolicy(meta: StackMeta, policy: LocalStackPolicy): StackMeta;
23
35
  export declare function saveStackMeta(meta: StackMeta, cwd?: string, stacksRoot?: string): Promise<void>;
24
36
  /** Explicit recovery. Does not touch branches, PRs, or registered server stacks. */
25
37
  export declare function resetStackMeta(cwd?: string, stacksRoot?: string): Promise<void>;
@@ -8,6 +8,11 @@ import path from "node:path";
8
8
  import { CommandError, isCommandErrorCode } from "./errors.js";
9
9
  import { git } from "./git.js";
10
10
  import { cliStacksRoot, gitTopLevel, LEGACY_STACK_META_REL, legacyStackMetaPath, repoStateDir, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
11
+ const LOCAL_STACK_FLAGS = [
12
+ "autoEnqueueWhenReady",
13
+ "autoReviewOverride",
14
+ "autoPatchOverride",
15
+ ];
11
16
  /** Exported only for legacy migration/tests. New state never uses this path. */
12
17
  export const STACK_META_REL = LEGACY_STACK_META_REL;
13
18
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
@@ -35,10 +40,18 @@ function normalizeMeta(raw) {
35
40
  if (!stack ||
36
41
  typeof stack !== "object" ||
37
42
  !Array.isArray(stack.layers) ||
38
- !stack.layers.every(isLayer)) {
43
+ !stack.layers.every(isLayer) ||
44
+ LOCAL_STACK_FLAGS.some((flag) => stack[flag] !== undefined &&
45
+ typeof stack[flag] !== "boolean")) {
39
46
  throw new Error("Stack authoring state has a malformed stack entry; run `mg stack reset --force` to clear it.");
40
47
  }
41
- stacks.push({ layers: stack.layers });
48
+ const entry = { layers: stack.layers };
49
+ for (const flag of LOCAL_STACK_FLAGS) {
50
+ const value = stack[flag];
51
+ if (typeof value === "boolean")
52
+ entry[flag] = value;
53
+ }
54
+ stacks.push(entry);
42
55
  }
43
56
  const active = obj.active;
44
57
  if (typeof active !== "number" ||
@@ -165,6 +178,22 @@ export function activeStack(meta) {
165
178
  export function activeLayers(meta) {
166
179
  return activeStack(meta).layers;
167
180
  }
181
+ export function setActiveAutoEnqueue(meta, enabled) {
182
+ return setActiveStackFlag(meta, "autoEnqueueWhenReady", enabled);
183
+ }
184
+ export function setActivePolicy(meta, policy) {
185
+ let next = meta;
186
+ for (const flag of LOCAL_STACK_FLAGS) {
187
+ next = setActiveStackFlag(next, flag, policy[flag]);
188
+ }
189
+ return next;
190
+ }
191
+ function setActiveStackFlag(meta, flag, value) {
192
+ if (value === undefined)
193
+ return meta;
194
+ const stacks = meta.stacks.map((stack, index) => index === meta.active ? { ...stack, [flag]: value } : stack);
195
+ return { ...meta, stacks };
196
+ }
168
197
  export async function saveStackMeta(meta, cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
169
198
  const file = stackMetaPath(cwd, stacksRoot);
170
199
  const dir = path.dirname(file);
@@ -229,7 +258,7 @@ export function appendLayer(meta, layer) {
229
258
  }
230
259
  }
231
260
  const stacks = meta.stacks.map((stack, index) => index === meta.active
232
- ? { layers: [...stack.layers, layer] }
261
+ ? { ...stack, layers: [...stack.layers, layer] }
233
262
  : stack);
234
263
  return { ...meta, stacks };
235
264
  }
package/dist/ui/banner.js CHANGED
@@ -81,6 +81,12 @@ const WHATS_NEW = {
81
81
  "mg pr --wait --after-sha, mg settings, mergestorm-pr-loop skill on npm",
82
82
  "mg skill install copies review + pr-loop from the packed tarball",
83
83
  ],
84
+ "0.3.16": [
85
+ "PR loop: verify each finding, prefer a small diff, post mergestorm-loop: dismiss on skip",
86
+ ],
87
+ "0.3.17": [
88
+ "nginx 413 names the upload limit instead of Failed: {}",
89
+ ],
84
90
  };
85
91
  function densityFromVariant(variant) {
86
92
  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.15",
3
+ "version": "0.3.17",
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
 
@@ -18,24 +18,36 @@ mg skill install --claude --cursor
18
18
  ## Preconditions (in order)
19
19
 
20
20
  1. There is an already-open GitHub PR on a Vortex-monitored repo. No PR, wrong skill.
21
- 2. Auto-patch (Cyclone) must be off so you are the only patcher. Do not take anyone's word for it: the settings API is the source of truth, and step 1 of the loop verifies it before anything else runs.
22
- 3. `whoami` works on the `mergestorm` MCP server (or `mg whoami --json` exits 0). Otherwise stop and ask for install/login.
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. 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`: continue. You are the only patcher.
29
- - If it is `true` and the human asked you to turn Cyclone off: call `settings_set` with `auto_patch_enabled: false`, then call `settings_get` again and confirm it now reads `false`.
30
- - If it is `true` otherwise: refuse the loop. Do not wait, patch, or push; tell the human Cyclone auto-patch is still on and where to turn it off.
31
- - Never set `auto_patch_enabled` to `true`.
32
- 2. Call `review_wait_pr` with the PR's owner, repo, and pr_number (`review_get_pr` works for a first look at the resting state).
33
- 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.
34
- 4. Read `findings.inline` and `findings.offDiff` (or `off_diff`) sorted by severity. Fix or explain each.
35
- 5. Patch on the PR head branch only.
36
- 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.
37
- 7. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex). An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
38
- 8. Repeat from step 3 until a stop rule fires.
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.
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.
39
+ - Never set `auto_patch_enabled` with `settings_set` from this skill, in either direction, and never set `auto_patch: true` on a stack.
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:
43
+ - Verify it against the current checkout. Do not treat the finding text as proven.
44
+ - Prefer the smallest correct patch. Do not refactor around a finding.
45
+ - Patch concrete bugs. A chat-only explanation is not a dismiss.
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.
39
51
 
40
52
  ## Stop rules
41
53
 
@@ -44,20 +56,23 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
44
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.
45
57
  - status is `rate_limited`, or `patch_policy.mode` is `"hold"`: surface the findings to the human and stop. Do not patch.
46
58
  - status is `failed`: report to the human. Do not keep pushing to retrigger.
47
- - 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`.
48
- - 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.
49
61
  - status is `skipped`, `stopped`, `trial_expired`, or `synced`: surface the status and reason to the human and stop. Do not patch or push.
50
62
 
51
63
  ## Never
52
64
 
53
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.
54
- - land, merge, enqueue, or touch stack authoring or the merge queue
55
- - turn `auto_patch_enabled` on, from this skill, for any reason
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
69
+ - set `auto_patch: true` on any stack
56
70
  - push any branch other than the PR head
57
71
  - force-push, or rebase and retry a push that was rejected because the remote moved
58
72
  - patch a pass whose `head_sha` is not the commit you have checked out
59
73
  - wrap the CLI in a shell escape hatch
60
74
  - claim a finding is fixed without a diff that touches its path
75
+ - claim a finding is dismissed without posting a `mergestorm-loop: dismiss` comment on the PR
61
76
 
62
77
  ## Fallback (no MCP)
63
78
 
@@ -67,10 +82,10 @@ Verify auto-patch with the CLI instead of `settings_get`:
67
82
  mg settings --json
68
83
  ```
69
84
 
70
- 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. If it is `true` and the human asked you to turn Cyclone off, run `mg settings --auto-patch off` and re-read; otherwise refuse the loop, same rule as step 1.
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`.
71
86
 
72
87
  ```bash
73
- 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>
74
89
  ```
75
90
 
76
- 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.