mergestorm 0.3.23 → 0.3.25

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.23 │
48
+ │ ▟██▛ mergestorm v0.3.25 │
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.23: leave a stack bottom on mg-stack-<n> │
53
+ │ New in v0.3.25: stack wait holds a PR while Cyclone or Vortex works │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -46,6 +46,13 @@ export declare const BEARER_SETTINGS: readonly [{
46
46
  readonly key: "auto_land_default";
47
47
  readonly flag: "--auto-land";
48
48
  readonly label: "Auto land (new stacks)";
49
+ }, {
50
+ readonly key: "auto_land_settle_seconds";
51
+ readonly flag: "--auto-land-settle";
52
+ readonly label: "Auto land settle (seconds)";
53
+ readonly kind: "seconds";
54
+ readonly min: 15;
55
+ readonly max: 300;
49
56
  }, {
50
57
  readonly key: "ignored_bot_logins";
51
58
  readonly flag: "--ignore-bot";
@@ -79,7 +86,9 @@ export type BearerSettingsValues = {
79
86
  [Row in (typeof BEARER_SETTINGS)[number] as Row["key"]]: Row extends {
80
87
  kind: "logins";
81
88
  } ? string[] : Row extends {
89
+ kind: "seconds";
90
+ } ? number : Row extends {
82
91
  values: readonly (infer Value)[];
83
92
  } ? Value : boolean;
84
93
  };
85
- export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("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" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
94
+ export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("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" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
@@ -11,6 +11,7 @@ export const BEARER_SETTINGS = [
11
11
  { key: "vortex_skip_all_clear_comments", flag: "--vortex-skip-all-clear", label: "Vortex skip All-clear comments" },
12
12
  { key: "vortex_seam_specialist_enabled", flag: "--vortex-seam", label: "Vortex seam specialist" },
13
13
  { key: "auto_land_default", flag: "--auto-land", label: "Auto land (new stacks)" },
14
+ { key: "auto_land_settle_seconds", flag: "--auto-land-settle", label: "Auto land settle (seconds)", kind: "seconds", min: 15, max: 300 },
14
15
  { key: "ignored_bot_logins", flag: "--ignore-bot", label: "Ignored bot logins", kind: "logins" },
15
16
  { key: "vortex_bot_skip_check", flag: "--vortex-skip-check", label: "Vortex bot skip check", kind: "enum", values: ["none", "neutral"] },
16
17
  { key: "vortex_findings_check", flag: "--vortex-findings", label: "Vortex findings check", kind: "enum", values: ["failure", "neutral", "success"] },
@@ -5,6 +5,6 @@ export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandE
5
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
7
  export { discoverTrunk } from "../git-stack.js";
8
- export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
- export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead, type StackBlocker } from "../stack-blockers.js";
8
+ export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
9
+ export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, type StackAgentBusy, type StackBlocker, type StackLayerAgents, } from "../stack-blockers.js";
@@ -5,6 +5,6 @@ export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandE
5
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
7
  export { discoverTrunk } from "../git-stack.js";
8
- export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
- export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead } from "../stack-blockers.js";
8
+ export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
9
+ export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, } from "../stack-blockers.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" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", 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" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", 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
  /**
@@ -144,8 +144,12 @@ async function removeQueue(args, asJson) {
144
144
  console.log(JSON.stringify(body, null, 2));
145
145
  return;
146
146
  }
147
+ const cancelRequested = typeof body === "object" && body !== null &&
148
+ body.cancelRequested === true;
147
149
  await present("Merge queue", [
148
- ansi.brightGreen(` Removed ${entryId} from the merge queue`),
150
+ cancelRequested
151
+ ? ansi.brightGreen(` Cancel requested for ${entryId}: it is merging, and the queue cancels it before the merge call unless the merge already started`)
152
+ : ansi.brightGreen(` Removed ${entryId} from the merge queue`),
149
153
  ]);
150
154
  }
151
155
  export async function cmdQueue(args, deps = {}) {
@@ -169,6 +169,7 @@ export const COMMAND_REGISTRY = [
169
169
  " [--cyclone-patch-unverified on|off] Patch languages we cannot typecheck",
170
170
  " [--vortex-seam on|off] [--vortex-skip-all-clear on|off]",
171
171
  " [--auto-land on|off] Auto land default for new stacks",
172
+ " [--auto-land-settle <seconds>] How long Auto land waits before queueing (15-300, default 60)",
172
173
  " [--ignore-bot add <login>|remove <login>|clear]",
173
174
  " [--vortex-skip-check none|neutral] [--vortex-findings failure|neutral|success]",
174
175
  " [--cyclone-fail-check failure|neutral]",
@@ -3,7 +3,7 @@ import { type SettingsPatch, type SettingsResponse } from "../api.js";
3
3
  * CLI flag → Bearer settings key. Boolean flags take on|off. 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" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
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" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
7
7
  type IgnoreBotOperation = {
8
8
  action: "add" | "remove";
9
9
  login: string;
@@ -46,6 +46,14 @@ export function parseSettingsArgs(args) {
46
46
  }
47
47
  continue;
48
48
  }
49
+ if ("kind" in row && row.kind === "seconds") {
50
+ const seconds = value !== undefined && /^\d+$/.test(value) ? Number(value) : Number.NaN;
51
+ if (!Number.isInteger(seconds) || seconds < row.min || seconds > row.max) {
52
+ throw new CommandError(`${flag} takes a whole number of seconds from ${row.min} through ${row.max}.`, 2, "usage");
53
+ }
54
+ Object.assign(patch, { [key]: seconds });
55
+ continue;
56
+ }
49
57
  if ("kind" in row && row.kind === "enum") {
50
58
  if (value === undefined || !row.values.includes(value)) {
51
59
  throw new CommandError(`${flag} takes ${row.values.join(" or ")}.`, 2, "usage");
@@ -76,13 +84,15 @@ export function formatSettingsLines(settings) {
76
84
  ? row.value.join(", ") || "(empty)"
77
85
  : typeof row.value === "string"
78
86
  ? row.value
79
- : row.writable
80
- ? row.value
81
- ? "on"
82
- : "off"
83
- : row.value
84
- ? "connected"
85
- : "not connected";
87
+ : typeof row.value === "number"
88
+ ? `${row.value}s`
89
+ : row.writable
90
+ ? row.value
91
+ ? "on"
92
+ : "off"
93
+ : row.value
94
+ ? "connected"
95
+ : "not connected";
86
96
  return ` ${row.label.padEnd(labelWidth + 2)}${value}`;
87
97
  });
88
98
  }
@@ -868,7 +868,7 @@ export async function cmdStackWait(args, deps = {}) {
868
868
  ...envelope,
869
869
  ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
870
870
  }, null, 2)
871
- : `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`);
871
+ : `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`);
872
872
  try {
873
873
  print(await (deps.pollStackWatch ?? pollStackWatch)(cfg, stackId, {
874
874
  timeoutMs: timeoutS * 1000,
@@ -1,4 +1,4 @@
1
- import { type MergeQueueEntryDto, type StackDto, type StackLayerDto } from "./stack-dto.js";
1
+ import { type MergeQueueEntryDto, type StackAgentsBusy, type StackDto, type StackLayerDto } from "./stack-dto.js";
2
2
  import type { StackWatchCursor } from "./stack-watch.js";
3
3
  export type StackBlocker = {
4
4
  prNumber: number;
@@ -6,6 +6,29 @@ export type StackBlocker = {
6
6
  blocker: string;
7
7
  bounceKind: string | null;
8
8
  };
9
+ export type StackAgentBusy = {
10
+ prNumber: number;
11
+ headSha: string | null;
12
+ agent: "vortex" | "cyclone";
13
+ blocker: string;
14
+ };
15
+ export type StackLayerAgents = {
16
+ prNumber: number;
17
+ headSha: string | null;
18
+ vortexStatus: StackLayerDto["vortexStatus"];
19
+ cycloneStatus: StackLayerDto["cycloneStatus"];
20
+ vortexReview: {
21
+ status: string;
22
+ headSha: string | null;
23
+ pass: number | null;
24
+ } | null;
25
+ busy: {
26
+ vortex: boolean;
27
+ cyclone: boolean;
28
+ };
29
+ };
30
+ export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
31
+ export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
9
32
  export declare function isMgParkBase(branch: string | null | undefined): boolean;
10
33
  /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
11
34
  export declare function boundDirty(layer: StackLayerDto): boolean;
@@ -20,6 +43,8 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
20
43
  headSha: string | null;
21
44
  } | null;
22
45
  bounce: MergeQueueEntryDto | undefined;
46
+ busy: StackAgentBusy[];
47
+ agents: StackLayerAgents | null;
23
48
  };
24
49
  /** Compact contract text shared by status and wait summaries. */
25
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[]): string;
50
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[]): string;
@@ -1,4 +1,19 @@
1
- import { mergeQueueBounceLabel, restackRetryPending } from "./stack-dto.js";
1
+ import { deriveStackAgentsBusy, mergeQueueBounceLabel, restackRetryPending, } from "./stack-dto.js";
2
+ export function layerAgentsBusy(layer) {
3
+ return layer.agentsBusy ?? deriveStackAgentsBusy(layer);
4
+ }
5
+ export function layerAgents(layer) {
6
+ const busy = layerAgentsBusy(layer);
7
+ const review = layer.vortexReview;
8
+ return {
9
+ prNumber: layer.prNumber,
10
+ headSha: layer.headSha ?? null,
11
+ vortexStatus: layer.vortexStatus ?? null,
12
+ cycloneStatus: layer.cycloneStatus ?? null,
13
+ vortexReview: review ? { status: review.status, headSha: review.head_sha, pass: review.pass } : null,
14
+ busy: { vortex: busy.vortex, cyclone: busy.cyclone },
15
+ };
16
+ }
2
17
  export function isMgParkBase(branch) {
3
18
  return (branch ?? "").trim().toLowerCase().startsWith("mg-park-");
4
19
  }
@@ -26,6 +41,22 @@ export function currentLayer(stack) {
26
41
  return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
27
42
  (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
28
43
  }
44
+ function seamGateBlocker(stack, layer) {
45
+ const members = stack.unit?.members ?? [];
46
+ if (layer.prNumber === stack.unit?.landPrNumber)
47
+ return null;
48
+ if (!members.some((member) => member.promotedHeadSha?.trim()))
49
+ return null;
50
+ const member = members.find((entry) => entry.prNumber === layer.prNumber);
51
+ if (!member || member.promotedHeadSha?.trim())
52
+ return null;
53
+ const seamState = member.seamState?.trim().toLowerCase();
54
+ if (seamState === "failed")
55
+ return "Seam review failed";
56
+ if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
57
+ return "Seam findings";
58
+ return null;
59
+ }
29
60
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
30
61
  function layerAttention(stack, layer, queue, cursor) {
31
62
  const none = { blocker: null, bounceKind: null };
@@ -63,6 +94,9 @@ function layerAttention(stack, layer, queue, cursor) {
63
94
  return hardBlock(`Tempest ${tempest.status}`);
64
95
  if (layer.vortexStatus === "failed")
65
96
  return hardBlock("Review failed");
97
+ const seam = seamGateBlocker(stack, layer);
98
+ if (seam)
99
+ return hardBlock(seam);
66
100
  if (stack.unit?.landPrNumber === layer.prNumber) {
67
101
  if (stack.unit.landingBlockReason?.trim())
68
102
  return hardBlock(stack.unit.landingBlockReason.trim());
@@ -94,6 +128,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
94
128
  (boundDirty(layer) || layer.state === "conflict" ||
95
129
  (layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
96
130
  let attention = null;
131
+ let attentionLayer;
97
132
  let bounce;
98
133
  const issues = [];
99
134
  for (const layer of open) {
@@ -105,16 +140,31 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
105
140
  blocker: result.blocker, bounceKind: result.bounceKind };
106
141
  if (selected || (!attention && layer === child)) {
107
142
  attention = issue;
143
+ attentionLayer = layer;
108
144
  bounce = result.bounce;
109
145
  }
110
146
  else if (!candidate || layer.position > candidate.position)
111
147
  issues.push(issue);
112
148
  }
113
- return { attention, issues, currentCandidate, bounce };
149
+ const focus = attentionLayer ?? candidate;
150
+ const agents = focus ? layerAgents(focus) : null;
151
+ const busy = [];
152
+ if (attention && agents) {
153
+ for (const agent of ["cyclone", "vortex"]) {
154
+ if (agents.busy[agent])
155
+ busy.push({ prNumber: attention.prNumber, headSha: attention.headSha, agent, blocker: attention.blocker });
156
+ }
157
+ }
158
+ if (busy.length)
159
+ return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents };
160
+ return { attention, issues, currentCandidate, bounce, busy, agents };
114
161
  }
115
162
  /** Compact contract text shared by status and wait summaries. */
116
- export function stackBlockersSummary(attention, issues) {
163
+ export function stackBlockersSummary(attention, issues, busy = []) {
117
164
  const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
165
+ const working = busy.length
166
+ ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map((entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews").join(" and ")}`
167
+ : "";
118
168
  const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
119
- return blocked + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
169
+ return blocked + working + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
120
170
  }
@@ -1,5 +1,5 @@
1
1
  export type RestackError = {
2
- kind: "rebase_conflict" | "checkout_failed" | "head_unresolved" | "push_failed";
2
+ kind: "rebase_conflict" | "checkout_failed" | "head_unresolved" | "push_failed" | "retarget_failed";
3
3
  detail: string;
4
4
  headSha: string;
5
5
  attemptedAt: string;
@@ -98,6 +98,16 @@ export type StackAgentRun = {
98
98
  total: number;
99
99
  } | null;
100
100
  };
101
+ export type StackAgentsBusy = {
102
+ vortex: boolean;
103
+ cyclone: boolean;
104
+ headSha: string | null;
105
+ };
106
+ export declare const STACK_AGENT_QUEUED_STALE_MS: number;
107
+ export declare function deriveStackAgentsBusy(layer: Pick<StackLayerDto, "headSha" | "vortexStatus" | "cycloneStatus" | "agentRuns" | "vortexReview">, opts?: {
108
+ cycloneClaimed?: boolean;
109
+ nowMs?: number;
110
+ }): StackAgentsBusy;
101
111
  export type StackLayerChecks = {
102
112
  total: number;
103
113
  success: number;
@@ -182,6 +192,7 @@ export type StackLayerDto = {
182
192
  * detail once it says `done`. Undefined = producer predates the viewer.
183
193
  */
184
194
  vortexReview?: StackVortexReviewView;
195
+ agentsBusy?: StackAgentsBusy | null;
185
196
  conflictDetail: string | null;
186
197
  restackError?: RestackError | null;
187
198
  lastRestackedSha: string | null;
@@ -300,11 +311,12 @@ export type StackDto = {
300
311
  * Partial or invalid server rows map to `null`, never to a guessed value.
301
312
  */
302
313
  autoEnqueueSettle?: {
303
- action: "ready" | "promote";
314
+ action: "ready" | "promote" | "mergeability";
304
315
  prNumber: number;
305
316
  headSha: string;
306
317
  /** ISO timestamp of the watcher's clock start. */
307
318
  startedAt: string;
319
+ windowSeconds?: number;
308
320
  } | null;
309
321
  /**
310
322
  * Per-stack Vortex auto-review override. `null` (or missing) follows the
@@ -356,6 +368,7 @@ export type MergeQueueBounceDetail = {
356
368
  failingCheck?: string;
357
369
  conflictBranch?: string;
358
370
  conflictDetail?: string;
371
+ conflictPaths?: string[];
359
372
  message?: string;
360
373
  };
361
374
  export type MergeQueueEntryDto = {
@@ -378,5 +391,6 @@ export type MergeQueueEntryDto = {
378
391
  verifyHeadSha: string | null;
379
392
  /** Target tip included in the verified PR head. Null until recorded. */
380
393
  verifyBaseSha: string | null;
394
+ cancelRequestedAt?: string | null;
381
395
  finishedAt: string | null;
382
396
  };
package/dist/stack-dto.js CHANGED
@@ -16,6 +16,33 @@ export function restackRetryPending(error, layer) {
16
16
  return false;
17
17
  return !/\(origin\/\S+ moved to /.test(error.detail) && pushRejectionIsRetryable(error.detail);
18
18
  }
19
+ export const STACK_AGENT_QUEUED_STALE_MS = 15 * 60 * 1000;
20
+ function agentShaMayBeHead(sha, head) {
21
+ const left = sha?.trim().toLowerCase();
22
+ const right = head?.trim().toLowerCase();
23
+ if (!left || !right)
24
+ return false;
25
+ return left.startsWith(right) || right.startsWith(left);
26
+ }
27
+ export function deriveStackAgentsBusy(layer, opts = {}) {
28
+ const headSha = layer.headSha?.trim() || null;
29
+ const nowMs = opts.nowMs ?? Date.now();
30
+ const runs = (layer.agentRuns ?? []).filter((run) => !run.finishedAt);
31
+ const vortexRuns = runs.filter((run) => run.agent === "vortex" && agentShaMayBeHead(run.sha, headSha));
32
+ const freshQueued = vortexRuns.some((run) => {
33
+ const queuedAt = Date.parse(run.startedAt ?? "");
34
+ return run.status === "queued" && Number.isFinite(queuedAt) && nowMs - queuedAt <= STACK_AGENT_QUEUED_STALE_MS;
35
+ });
36
+ const review = layer.vortexReview;
37
+ const reviewOnHead = !!review && agentShaMayBeHead(review.head_sha, headSha);
38
+ const vortex = (reviewOnHead && review.status === "reviewing") ||
39
+ vortexRuns.some((run) => run.status === "reviewing") ||
40
+ freshQueued ||
41
+ (layer.agentRuns === undefined && review === undefined && layer.vortexStatus === "reviewing");
42
+ const cyclone = opts.cycloneClaimed === true || layer.cycloneStatus === "patching" ||
43
+ runs.some((run) => run.agent === "cyclone" && run.status === "patching");
44
+ return { vortex, cyclone, headSha };
45
+ }
19
46
  /** Every bounce kind understood by the shared contract. */
20
47
  export const MERGE_QUEUE_BOUNCE_KINDS = [
21
48
  "ci_failure",
@@ -47,6 +74,7 @@ export function mergeQueueBounceLabel(entry) {
47
74
  if (kindLabel) {
48
75
  const specifics = detail?.failingCheck ??
49
76
  detail?.conflictBranch ??
77
+ (detail?.conflictPaths?.length ? detail.conflictPaths.join(", ") : null) ??
50
78
  detail?.message ??
51
79
  (detail?.prNumber != null ? `PR #${detail.prNumber}` : null);
52
80
  return specifics ? `${kindLabel} — ${specifics}` : kindLabel;
@@ -1,8 +1,9 @@
1
- import { type StackBlocker } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackLayerAgents } from "./stack-blockers.js";
2
2
  import { apiFetch } from "./api.js";
3
3
  import type { Config } from "./config.js";
4
4
  export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
5
5
  export declare const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45000;
6
+ export declare const STACK_WATCH_BUSY_RECHECK_S = 15;
6
7
  /** Resume selectors are fixed for the entire wait, including transient retries. */
7
8
  export type StackWatchCursor = {
8
9
  readonly stackId: string;
@@ -25,6 +26,8 @@ export type StackWatchEnvelope = {
25
26
  headSha: string | null;
26
27
  } | null;
27
28
  assessment: "available" | "unavailable";
29
+ busy: StackAgentBusy[];
30
+ agents: StackLayerAgents | null;
28
31
  };
29
32
  export type PollStackWatchOptions = {
30
33
  timeoutMs?: number;
@@ -1,9 +1,10 @@
1
- import { currentLayer, stackBlockers } from "./stack-blockers.js";
1
+ import { currentLayer, layerAgents, stackBlockers, } from "./stack-blockers.js";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { apiFetch } from "./api.js";
4
4
  import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
5
5
  export const STACK_WATCH_SCHEMA = "mergestorm.stack_watch/v1";
6
6
  export const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45_000;
7
+ export const STACK_WATCH_BUSY_RECHECK_S = 15;
7
8
  export class StackWatchTimeoutError extends Error {
8
9
  lastEnvelope;
9
10
  constructor(lastEnvelope) {
@@ -46,7 +47,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
46
47
  let lastEnvelope = {
47
48
  schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
48
49
  bounceKind: null, prNumber: null, headSha: null, cursor,
49
- issues: [], currentCandidate: null, assessment: "unavailable",
50
+ issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null,
50
51
  };
51
52
  let transientFailures = 0;
52
53
  const timeout = () => new StackWatchTimeoutError(lastEnvelope);
@@ -122,15 +123,16 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
122
123
  };
123
124
  };
124
125
  const evaluate = (stack, entries) => {
125
- const { attention, issues, currentCandidate, bounce } = stackBlockers(stack, entries, cursor);
126
+ const { attention, issues, currentCandidate, bounce, busy, agents } = stackBlockers(stack, entries, cursor);
126
127
  if (bounce)
127
128
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
128
129
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
129
- lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available",
130
+ const held = busy[0];
131
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents,
130
132
  blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
131
- prNumber: attention?.prNumber ?? currentCandidate?.prNumber ?? null,
132
- headSha: attention ? attention.headSha : currentCandidate?.headSha ?? null,
133
- status: attention ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
133
+ prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
134
+ headSha: attention ? attention.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
135
+ status: attention ? "attention" : busy.length || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
134
136
  ? "in_progress" : "waiting" };
135
137
  };
136
138
  let stack = await enrich(timeoutMs === 0);
@@ -139,14 +141,15 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
139
141
  cursor = Object.freeze({ stackId: id, enrolledHeadSha: layer?.headSha ?? null });
140
142
  enrolled = true;
141
143
  }
142
- lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
144
+ lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null,
145
+ agents: layer ? layerAgents(layer) : null };
143
146
  let queue = await readQueue(queueRoute, timeoutMs === 0);
144
147
  evaluate(stack, queue.entries);
145
148
  if (lastEnvelope.status === "attention" || timeoutMs === 0)
146
149
  return lastEnvelope;
147
150
  while (now() < deadline) {
148
151
  const heldAt = now();
149
- const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
152
+ const seconds = Math.min(lastEnvelope.busy.length ? STACK_WATCH_BUSY_RECHECK_S : 45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
150
153
  const seen = queue.fingerprint ? `&seen=${encodeURIComponent(queue.fingerprint)}` : "";
151
154
  const held = await readQueue(`${queueRoute}&wait=${seconds}${seen}`);
152
155
  if (JSON.stringify(held.entries) === JSON.stringify(queue.entries)) {
@@ -155,7 +158,8 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
155
158
  queue = held;
156
159
  stack = await enrich(true);
157
160
  const refreshed = currentLayer(stack);
158
- lastEnvelope = { ...lastEnvelope, prNumber: refreshed?.prNumber ?? null, headSha: refreshed?.headSha ?? null };
161
+ lastEnvelope = { ...lastEnvelope, prNumber: refreshed?.prNumber ?? null, headSha: refreshed?.headSha ?? null,
162
+ agents: refreshed ? layerAgents(refreshed) : null };
159
163
  transientFailures = 0;
160
164
  evaluate(stack, queue.entries);
161
165
  if (lastEnvelope.status === "attention")
package/dist/ui/banner.js CHANGED
@@ -102,6 +102,12 @@ const WHATS_NEW = {
102
102
  "0.3.23": [
103
103
  "leave a stack bottom on mg-stack-<n>",
104
104
  ],
105
+ "0.3.24": [
106
+ "mg settings --auto-land-settle (15-300 seconds)",
107
+ ],
108
+ "0.3.25": [
109
+ "stack wait holds a PR while Cyclone or Vortex works",
110
+ ],
105
111
  };
106
112
  function densityFromVariant(variant) {
107
113
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
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
@@ -68,12 +68,12 @@ Call `review_submit` with `wait: false` (the default) on the repo cwd (git colle
68
68
 
69
69
  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.
70
70
 
71
- 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.
71
+ Call `review_list` before any resubmit, including when a submit or wait call is interrupted. Resume the existing job with `review_wait` (`timeout_s: 0` reads it once without waiting); do not create a second job because a slice died or the submit response was lost.
72
72
 
73
73
  Status handling:
74
74
 
75
75
  - status is `no_changes`: success. The branch matches the selected base, no review job was submitted, and no review credit was used. Stop unless the user expected a different base.
76
- - status is `rate_limited`: wait `retry_after_seconds`. Use `review_list`, `review_get`, or `review_wait` for an existing in-flight job before resubmitting. Do not immediately resubmit the same diff.
76
+ - status is `rate_limited`: wait `retry_after_seconds`. Use `review_list` or `review_wait` for an existing in-flight job before resubmitting. Do not immediately resubmit the same diff.
77
77
 
78
78
  ## Apply findings
79
79
 
@@ -16,10 +16,10 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
16
16
  ## Watch and patch
17
17
 
18
18
  1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument.
19
- 2. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
20
- 3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy.
19
+ 2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
20
+ 3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy. A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. While `busy[]` holds a PR it can hide a blocker no agent will clear, such as a Draft PR. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off, and do not turn Auto land off or on, to end the wait. If the remote head moves while you wait, stop and tell the human.
21
21
  4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
22
- 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention.
22
+ 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`.
23
23
  6. Recheck Auto land and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
24
24
  7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
25
25
 
@@ -41,7 +41,7 @@ mg skill install --claude --cursor --agents
41
41
  - 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.
42
42
  - 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.
43
43
  - Never set `auto_patch_enabled` with `settings_set` from this skill, in either direction, and never set `auto_patch: true` on a stack.
44
- 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`.
44
+ 3. Call `review_wait_pr` with the PR's owner, repo, pr_number, and `timeout_s` 45 (`timeout_s` 0 reads the current state once without waiting). 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`.
45
45
  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.
46
46
  5. Read `findings.inline` and `findings.offDiff` (or `off_diff`) sorted by severity. For each finding:
47
47
  - Verify it against the current checkout. Do not treat the finding text as proven.
@@ -50,7 +50,7 @@ mg skill install --claude --cursor --agents
50
50
  - 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.
51
51
  6. Patch on the PR head branch only.
52
52
  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.
53
- 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.
53
+ 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. `pass` reads one exact attempt; `timeout_s` 0 reads once without waiting. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
54
54
  9. Repeat from step 4 until a stop rule fires.
55
55
 
56
56
  ## Stop rules
@@ -68,7 +68,7 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
68
68
 
69
69
  ## Never
70
70
 
71
- - 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.
71
+ - submit a local review job, or call the job-id tool `review_wait` here. Local jobs are `mergestorm-review`'s world: they cost credits and do not fire Vortex on the PR.
72
72
  - land, merge, enqueue, or touch stack authoring beyond adopting the existing PR, or the merge queue
73
73
  - 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
74
74
  - treat the key user's mute or `auto_patch_enabled` as covering a different Cyclone install user