mergestorm 0.3.34 → 0.3.35

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,7 +45,7 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.34 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.35 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -54,12 +54,27 @@ export type StackRepairHint = {
54
54
  branch: string;
55
55
  findings: string;
56
56
  steps: string;
57
+ } | {
58
+ kind: "vortex_findings";
59
+ prNumber: number;
60
+ headSha: string | null;
61
+ branch: string;
62
+ handoff: string | null;
63
+ findings: string;
64
+ steps: string;
57
65
  } | {
58
66
  kind: "unit_abandoned";
59
67
  prNumber: number;
60
68
  headSha: string | null;
61
69
  branch: string;
62
70
  steps: string;
71
+ } | {
72
+ kind: "seam_review_stuck";
73
+ prNumber: number;
74
+ headSha: string | null;
75
+ branch: string;
76
+ seamState: string;
77
+ steps: string;
63
78
  } | {
64
79
  kind: "auto_land_off";
65
80
  prNumber: number;
@@ -67,6 +82,21 @@ export type StackRepairHint = {
67
82
  branch: string;
68
83
  bounceKind: string;
69
84
  steps: string;
85
+ } | {
86
+ kind: "ci_pending" | "vortex_not_green";
87
+ prNumber: number;
88
+ headSha: string | null;
89
+ branch: string;
90
+ since: string;
91
+ steps: string;
92
+ } | {
93
+ kind: "promote_failed";
94
+ prNumber: number;
95
+ headSha: string | null;
96
+ branch: string;
97
+ attempts: number;
98
+ message: string | null;
99
+ steps: string;
70
100
  };
71
101
  export type StackLandGatePending = {
72
102
  prNumber: number;
@@ -84,12 +114,21 @@ export declare function sameHead(a: string | null | undefined, b: string | null
84
114
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
85
115
  export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
86
116
  export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
117
+ export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
118
+ export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
119
+ export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
120
+ export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
121
+ export declare const AUTO_LAND_CI_PENDING_ATTENTION_MS: number;
122
+ export declare const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS: number;
123
+ export declare const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
124
+ export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land reads a failing check on GitHub at this head";
125
+ export declare function autoLandWaitBlocker(stack: StackDto, layer: StackLayerDto, nowMs: number): string | null;
87
126
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
88
127
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
89
128
  export declare function agentsCannotClear(blocker: string): boolean;
90
129
  export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
91
130
  /** Pair-gate attention plus live hard blocks on every other open layer. */
92
- export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
131
+ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
93
132
  attention: null;
94
133
  held: StackHeldBlocker;
95
134
  issues: StackBlocker[];
@@ -53,6 +53,30 @@ export function currentLayer(stack) {
53
53
  }
54
54
  export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
55
55
  export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
56
+ export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
57
+ export const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
58
+ export const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
59
+ const STUCK_SEAM_BLOCKERS = [SEAM_REVIEW_STUCK, SEAM_REREVIEW_NOT_RUNNING, SEAM_VERDICT_STALE];
60
+ function coreReviewDoneAtHead(layer) {
61
+ const review = layer.vortexReview;
62
+ return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
63
+ }
64
+ export const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
65
+ function vortexChangesRequestedAtHead(layer) {
66
+ const review = layer.vortexReview;
67
+ if (review && !(review.status === "done" && sameHead(review.head_sha, layer.headSha)))
68
+ return false;
69
+ return layer.vortexStatus === "findings" && layer.reviewStatus === "changes_requested";
70
+ }
71
+ function cycloneHandoffAtHead(stack, layer) {
72
+ if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
73
+ return layer.cycloneHandoff.reason;
74
+ return stack.autoPatchOverride === false ? "auto_patch_off" : null;
75
+ }
76
+ function vortexFindingsLeftForPerson(stack, layer) {
77
+ return vortexChangesRequestedAtHead(layer) && !layerAgentsBusy(layer).cyclone &&
78
+ cycloneHandoffAtHead(stack, layer) !== null;
79
+ }
56
80
  function quotaSkippedReview(layer) {
57
81
  const review = layer.vortexReview;
58
82
  return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
@@ -72,8 +96,20 @@ function seamGateBlocker(stack, layer) {
72
96
  return "Seam review failed";
73
97
  if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
74
98
  return "Seam findings";
75
- if (seamState === "pending" && !layerAgentsBusy(layer).vortex)
99
+ if (layerAgentsBusy(layer).vortex)
100
+ return null;
101
+ if (seamState === "pending")
76
102
  return SEAM_REVIEW_NOT_RUNNING;
103
+ if (seamState === "reviewing")
104
+ return SEAM_REVIEW_STUCK;
105
+ if (seamState === "pending_rereview")
106
+ return SEAM_REREVIEW_NOT_RUNNING;
107
+ if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
108
+ return null;
109
+ if (seamState === "findings")
110
+ return SEAM_VERDICT_STALE;
111
+ if (seamState === "approved" && !coreReviewDoneAtHead(layer))
112
+ return SEAM_VERDICT_STALE;
77
113
  return null;
78
114
  }
79
115
  function headChecksAllGreen(layer) {
@@ -136,6 +172,8 @@ function layerAttention(stack, layer, queue, cursor) {
136
172
  if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
137
173
  return hardBlock("Tempest failed");
138
174
  }
175
+ if (vortexFindingsLeftForPerson(stack, layer))
176
+ return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
139
177
  }
140
178
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
141
179
  return none;
@@ -179,6 +217,52 @@ function disarmingBounce(stack, queue) {
179
217
  return recorded;
180
218
  return undefined;
181
219
  }
220
+ export const AUTO_LAND_CI_PENDING_ATTENTION_MS = 45 * 60_000;
221
+ export const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS = 30 * 60_000;
222
+ export const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
223
+ export const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed — Auto land reads a failing check on GitHub at this head";
224
+ const AUTO_LAND_CI_PENDING_PREFIX = "CI pending for ";
225
+ const AUTO_LAND_VORTEX_WAIT_PREFIX = "No green Vortex review for ";
226
+ const AUTO_LAND_PROMOTE_FAILED_PREFIX = "Promote failed ";
227
+ function waitedMs(since, nowMs) {
228
+ const sinceMs = Date.parse(since);
229
+ return Number.isFinite(sinceMs) ? Math.max(0, nowMs - sinceMs) : null;
230
+ }
231
+ function autoLandWaitOn(stack, layer) {
232
+ const wait = stack.autoLandWait;
233
+ if (!wait || stack.archivedAt || stack.autoEnqueueWhenReady !== true)
234
+ return null;
235
+ if (wait.prNumber !== layer.prNumber || !sameHead(wait.headSha, layer.headSha))
236
+ return null;
237
+ return wait;
238
+ }
239
+ export function autoLandWaitBlocker(stack, layer, nowMs) {
240
+ const wait = autoLandWaitOn(stack, layer);
241
+ if (!wait)
242
+ return null;
243
+ const waited = waitedMs(wait.since, nowMs);
244
+ if (waited == null)
245
+ return null;
246
+ const minutes = Math.floor(waited / 60_000);
247
+ switch (wait.reason) {
248
+ case "ci_failure":
249
+ return AUTO_LAND_CI_FAILED_BLOCKER;
250
+ case "ci_pending":
251
+ return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
252
+ ? `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`
253
+ : null;
254
+ case "vortex_not_green":
255
+ return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS
256
+ ? `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`
257
+ : null;
258
+ case "promote_merge_failed":
259
+ return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
260
+ ? `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`
261
+ : null;
262
+ default:
263
+ return null;
264
+ }
265
+ }
182
266
  function isTerminalLayer(layer) {
183
267
  return layer.state === "merged" || layer.state === "closed";
184
268
  }
@@ -222,6 +306,7 @@ function landPrLiveParent(stack, layer) {
222
306
  export function agentsCannotClear(blocker) {
223
307
  return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
224
308
  blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA ||
309
+ STUCK_SEAM_BLOCKERS.includes(blocker) ||
225
310
  blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
226
311
  }
227
312
  function isTempestFindingsBlocker(attention) {
@@ -257,6 +342,22 @@ export function stackRepair(stack, attention, layer, bounce) {
257
342
  steps: `The stack's review unit was abandoned when #${layer.prNumber}'s base on GitHub was changed off its mg-stack trunk, so Auto land cannot promote or land this stack. Ask the human whether to re-root it. ${openPrCount < 2 ? `With only one open PR, re-adopting #${layer.prNumber} will not create a review unit; close and resubmit it to create a fresh stack.` : `If yes, tell the human that mg stack adopt ${stack.owner}/${stack.repo}#${layer.prNumber} re-roots it on a new review unit.`} Do not retarget the PR base yourself.`,
258
343
  };
259
344
  }
345
+ if (STUCK_SEAM_BLOCKERS.includes(attention.blocker)) {
346
+ const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
347
+ const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
348
+ const reviewed = member?.seamReviewedSha?.trim();
349
+ const what = attention.blocker === SEAM_VERDICT_STALE
350
+ ? `The seam (integration) review verdict on #${layer.prNumber} (${seamState}) was made at ${reviewed ? reviewed.slice(0, 7) : "an older head"}, not the live head ${layer.headSha ?? "(unknown)"}, and no seam review is queued or running to replace it`
351
+ : `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
352
+ return {
353
+ kind: "seam_review_stuck",
354
+ prNumber: layer.prNumber,
355
+ headSha: layer.headSha ?? null,
356
+ branch: layer.branch,
357
+ seamState,
358
+ steps: `${what}, so Auto land cannot promote it. Do not patch or push for this. Tell the human to click Continue on the stack in the Mergestorm dashboard, which re-queues the seam review at the live head of ${layer.branch}; a new commit pushed to ${layer.branch} also queues one. Then keep watching.`,
359
+ };
360
+ }
260
361
  if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
261
362
  return {
262
363
  kind: "auto_land_off",
@@ -278,6 +379,52 @@ export function stackRepair(stack, attention, layer, bounce) {
278
379
  steps: `Tempest never patches, and Cyclone is not patching this land PR. Read the Tempest report on it (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch on ${layer.branch}, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Do not retarget the PR base.`,
279
380
  };
280
381
  }
382
+ const wait = autoLandWaitOn(stack, layer);
383
+ if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
384
+ return {
385
+ kind: "ci_pending",
386
+ prNumber: layer.prNumber,
387
+ headSha: layer.headSha ?? null,
388
+ branch: layer.branch,
389
+ since: wait.since,
390
+ steps: `A check on ${layer.branch} at ${layer.headSha ?? "its head"} has been pending since ${wait.since}, and Auto land will not queue an unfinished head. List the head's checks (gh api repos/${stack.owner}/${stack.repo}/commits/${layer.headSha ?? "<sha>"}/check-runs, and gh run list --branch ${layer.branch}) and find the one that never finished. Re-run it (gh run rerun <run-id>) if it is stuck or was never picked up. If it is a required check that no workflow reports, or it fails again, tell the human. Push a fix only when the check is red for a reason in the code. Do not retarget the PR base.`,
391
+ };
392
+ }
393
+ if (wait && attention.blocker.startsWith(AUTO_LAND_VORTEX_WAIT_PREFIX)) {
394
+ return {
395
+ kind: "vortex_not_green",
396
+ prNumber: layer.prNumber,
397
+ headSha: layer.headSha ?? null,
398
+ branch: layer.branch,
399
+ since: wait.since,
400
+ steps: `Auto land has waited since ${wait.since} for an approving Vortex review of ${layer.branch} at ${layer.headSha ?? "its head"}, and no Vortex run is working on it. Read the PR's latest Vortex review (gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews). If it has findings, verify and fix the real ones on ${layer.branch} with an ordinary push, or record a wrong one with mg dismiss as mergestorm-pr-loop describes. If no review exists at this head, or it failed or was skipped, tell the human: nothing re-reviews this head until they re-run the review or push a new head.`,
401
+ };
402
+ }
403
+ if (wait && attention.blocker.startsWith(AUTO_LAND_PROMOTE_FAILED_PREFIX)) {
404
+ const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
405
+ return {
406
+ kind: "promote_failed",
407
+ prNumber: layer.prNumber,
408
+ headSha: layer.headSha ?? null,
409
+ branch: layer.branch,
410
+ attempts,
411
+ message: wait.detail,
412
+ steps: `Auto land tried to promote #${layer.prNumber} at ${layer.headSha ?? "its head"} ${attempts} times and GitHub refused the merge each time${wait.detail ? ` (${wait.detail})` : ""}, so it stopped retrying this head. Read why: gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber} --jq '{mergeable, mergeable_state, base: .base.ref}', and the branch protection on that base. If the cause is in ${layer.branch}, fix it and push with an ordinary push: a new head is retried. If it is outside the PR (branch protection, permissions, a required review), tell the human, and that once it is fixed they can retry the promote by turning Auto land off and on (mg stack set ${stack.id} --auto-land off, then --auto-land on). Do not toggle Auto land yourself, and do not retarget the PR base.`,
413
+ };
414
+ }
415
+ if (attention.blocker === VORTEX_FINDINGS_NEED_PERSON) {
416
+ const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
417
+ const handoff = cycloneHandoffAtHead(stack, layer);
418
+ return {
419
+ kind: "vortex_findings",
420
+ prNumber: layer.prNumber,
421
+ headSha: layer.headSha ?? null,
422
+ branch: layer.branch,
423
+ handoff,
424
+ findings,
425
+ steps: `Vortex requested changes on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${handoff.replaceAll("_", " ")})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
426
+ };
427
+ }
281
428
  if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
282
429
  const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
283
430
  return {
@@ -292,7 +439,7 @@ export function stackRepair(stack, attention, layer, bounce) {
292
439
  return null;
293
440
  }
294
441
  /** Pair-gate attention plus live hard blocks on every other open layer. */
295
- export function stackBlockers(stack, entries = [], cursor = {}) {
442
+ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
296
443
  const candidate = currentLayer(stack);
297
444
  const currentCandidate = candidate ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null } : null;
298
445
  const queue = entries.filter((entry) => entry.stackId.trim().toLowerCase() === stack.id.trim().toLowerCase());
@@ -318,6 +465,14 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
318
465
  result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
319
466
  }
320
467
  }
468
+ let waitNamed = false;
469
+ if (!result.blocker) {
470
+ const waiting = autoLandWaitBlocker(stack, layer, nowMs);
471
+ if (waiting) {
472
+ result = { blocker: waiting, bounceKind: null };
473
+ waitNamed = true;
474
+ }
475
+ }
321
476
  if (!result.blocker) {
322
477
  if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
323
478
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
@@ -329,7 +484,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
329
484
  blocker: result.blocker, bounceKind: result.bounceKind };
330
485
  if (selected ||
331
486
  result.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX) ||
332
- (!attention && layer === child)) {
487
+ (!attention && (layer === child || waitNamed))) {
333
488
  attention = issue;
334
489
  attentionLayer = layer;
335
490
  bounce = result.bounce;
@@ -64,6 +64,11 @@ export type StackVortexReviewView = {
64
64
  source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
65
65
  };
66
66
  export type StackCycloneStatus = "patching" | "awaiting_fix" | "failed";
67
+ export type StackCycloneHandoff = {
68
+ headSha: string;
69
+ reason: string;
70
+ at: string | null;
71
+ };
67
72
  export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
68
73
  export type StackAgentName = "vortex" | "cyclone" | "tempest";
69
74
  /**
@@ -208,6 +213,7 @@ export type StackLayerDto = {
208
213
  */
209
214
  vortexReview?: StackVortexReviewView;
210
215
  agentsBusy?: StackAgentsBusy | null;
216
+ cycloneHandoff?: StackCycloneHandoff | null;
211
217
  conflictDetail: string | null;
212
218
  restackError?: RestackError | null;
213
219
  lastRestackedSha: string | null;
@@ -339,6 +345,14 @@ export type StackDto = {
339
345
  startedAt: string;
340
346
  windowSeconds?: number;
341
347
  } | null;
348
+ autoLandWait?: {
349
+ reason: string;
350
+ prNumber: number | null;
351
+ headSha: string | null;
352
+ since: string;
353
+ attempts: number | null;
354
+ detail: string | null;
355
+ } | null;
342
356
  /**
343
357
  * Per-stack Vortex auto-review override. `null` (or missing) follows the
344
358
  * account `auto_review_enabled` flag; a boolean wins in both directions.
@@ -140,7 +140,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
140
140
  };
141
141
  };
142
142
  const evaluate = (stack, entries) => {
143
- const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
143
+ const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor, now());
144
144
  if (bounce)
145
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
146
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
package/dist/ui/banner.js CHANGED
@@ -136,6 +136,11 @@ const WHATS_NEW = {
136
136
  "stack watch names an abandoned review unit",
137
137
  "stack watch names Auto land's mergeability wait",
138
138
  ],
139
+ "0.3.35": [
140
+ "stack watch names why Auto land is waiting",
141
+ "stack watch names a stuck seam review",
142
+ "stack watch names findings Cyclone left for you",
143
+ ],
139
144
  };
140
145
  function densityFromVariant(variant) {
141
146
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.34",
3
+ "version": "0.3.35",
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",
@@ -35,9 +35,9 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
35
35
 
36
36
  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. `watch.next.args` already holds them in that shape, with `timeout_s: 45`; passing it unchanged is the same call.
37
37
  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. Ending your turn while `mg stack watch` runs in the background is waiting, not finishing. 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, call `stack_wait` once more with the same cursor; if it fails again, stop and report the reason and that the stack is not landed. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
38
- 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. `repair` does not replace the checks below. 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; the only policy change you make is the re-arm in "Re-arm after a bounce". 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. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review or Tempest run, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
38
+ 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. Auto land records why it is waiting in the stack's `autoLandWait` (`reason`, `prNumber`, `headSha`, `since`), and the watch returns `attention` on that PR when the wait cannot clear on its own: a failing check Auto land reads live on GitHub while the ledger may still show CI green (blocker `CI failed — Auto land reads a failing check on GitHub at this head`, `repair` kind `ci_failure`); a check pending for 45 minutes (`repair` kind `ci_pending`: find the check that never finished and re-run it, or tell the human when no workflow reports it); no approving Vortex review for 30 minutes with no Vortex run working (`repair` kind `vortex_not_green`: fix or dismiss the findings, or tell the human when no review exists at the head); and a promote GitHub refused 3 times with `merge_failed`, after which Auto land stops retrying that head (`repair` kind `promote_failed`: fix the cause on the PR and push a new head, or tell the human when the cause is outside the PR; never toggle Auto land yourself for it). A layer whose live head has a Vortex verdict that requests changes after Cyclone left those findings for a person (a hold directive, the Vortex patch cap, auto-patch off, no billing, a batch it gave up on, or a PR state it could not read) returns `attention` with the blocker `Vortex findings need a person` and a `repair` of kind `vortex_findings` naming the Cyclone reason (`handoff`) and where the review is (`findings`): verify each finding against the checkout, fix the real ones with the smallest patch and an ordinary push, and tell the human about the ones you judge wrong. `repair` does not replace the checks below. 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; the only policy change you make is the re-arm in "Re-arm after a bounce". 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. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review or Tempest run, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
39
39
  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. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer. Tempest never patches: Cyclone patches a land PR when auto-patch is on for that stack, otherwise you do. On `tempest_findings` on the land PR, when `busy[]` shows no Cyclone run on it, read the Tempest report, verify each finding against the checkout as for Vortex findings, fix the real ones with the smallest patch on `mg-stack-N`, and push with an ordinary push, never a force push; while Cyclone is patching it the envelope stays `in_progress` with `busy[]`, so wait. 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-*`.
40
- 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. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
40
+ 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. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Seam review stuck in reviewing, no review running`, `Seam re-review pending, no review running`, or `Seam verdict is for an older head, no review running` (repair kind `seam_review_stuck`), do not patch or push; tell the human: no seam review is queued or running for that PR's live head, and clicking Continue on the stack re-queues it (a new pushed head also does). On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
41
41
  6. Recheck Auto land (off is expected only after a disarming bounce you are handling) 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.
42
42
  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 (in the background: `mg stack watch <stack-id> --head <pushed-sha>`). 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 anything. If the bounce you handled turned Auto land off, turn it back on as "Re-arm after a bounce" allows; if that section does not allow it, report and stop.
43
43