mergestorm 0.3.25 → 0.3.27

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.
@@ -14,7 +14,7 @@ export type BannerDensity = "full" | "compact" | "mini" | "nano";
14
14
  export type BannerVariant = boolean | "mini" | "nano";
15
15
  /** Handle returned by {@link printBannerHeader}; `rows` reflows for a resize. */
16
16
  export type BannerHandle = {
17
- rows(columns?: number, variant?: BannerVariant): string[];
17
+ rows(columns?: number, variant?: BannerVariant, frame?: number): string[];
18
18
  };
19
19
  /**
20
20
  * The whole welcome panel as rows, reflowed to `columns`. Every row fits in
@@ -24,6 +24,7 @@ export type BannerHandle = {
24
24
  export declare function buildBannerRows(state: BannerState, columns?: number, opts?: {
25
25
  compact?: boolean;
26
26
  density?: BannerDensity;
27
+ frame?: number;
27
28
  }): string[];
28
29
  /**
29
30
  * Print the welcome panel and return a handle whose `rows()` reflows the
package/dist/ui/banner.js CHANGED
@@ -3,8 +3,8 @@ import { CommandError } from "../errors.js";
3
3
  import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { cliVersion } from "../version.js";
5
5
  import { ansi } from "./ansi.js";
6
- import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
7
- import { STORM_MARK, STORM_MARK_WIDTH } from "./logo.js";
6
+ import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, terminalColumns, visibleWidth, } from "./box.js";
7
+ import { paintSpriteRow, TORNADO_LOGO, TORNADO_LOGO_WIDTH } from "./logo.js";
8
8
  import { formatResetLabel, usageBar } from "./usage.js";
9
9
  const WORDMARK = `
10
10
  __ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
@@ -108,6 +108,12 @@ const WHATS_NEW = {
108
108
  "0.3.25": [
109
109
  "stack wait holds a PR while Cyclone or Vortex works",
110
110
  ],
111
+ "0.3.26": [
112
+ "stack results say the watch ends only at land",
113
+ ],
114
+ "0.3.27": [
115
+ "mg dismiss records an audited finding dismissal",
116
+ ],
111
117
  };
112
118
  function densityFromVariant(variant) {
113
119
  if (variant === "mini" || variant === "nano")
@@ -199,57 +205,30 @@ export function buildBannerRows(state, columns, opts) {
199
205
  const contentCells = boxed
200
206
  ? Math.max(20, outer - 4)
201
207
  : Math.max(20, terminalColumns(80, columns) - 1);
202
- const indent = STORM_MARK_WIDTH + 2;
203
- const pad = " ".repeat(indent);
208
+ const markLines = TORNADO_LOGO;
209
+ const markWidth = TORNADO_LOGO_WIDTH;
210
+ const gap = 2;
211
+ const rightCells = Math.max(8, contentCells - markWidth - gap);
204
212
  const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
205
- const mark = (row) => padVisible(ansi.brightGreen(STORM_MARK[row] ?? ""), indent);
213
+ const clipRight = (line) => visibleWidth(line) > rightCells ? padVisible(line, rightCells) : line;
206
214
  const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
207
- if (density === "nano") {
208
- const one = `${mark(0)}${title} ${statusLine(state)}`;
209
- return [clip(one)];
210
- }
211
- if (density === "mini") {
212
- // Short pane: mark + who you are, then the same cues as the full home.
213
- const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
214
- const line1 = state.me && !state.keyInvalid
215
- ? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
216
- : clip(mark(1));
217
- const room = contentCells - indent;
218
- const blurb = tagline(room);
219
- const tips = tipsLine(room);
220
- const fresh = whatsNewLine(state.version, room);
221
- return [
222
- line0,
223
- line1,
224
- ...(blurb ? [clip(`${pad}${blurb}`)] : []),
225
- ...(tips ? [clip(`${pad}${tips}`)] : []),
226
- ...(fresh ? [clip(`${pad}${fresh}`)] : []),
227
- ];
228
- }
229
- const content = [
230
- clip(`${pad}${title}`),
231
- clip(`${mark(0)}${statusLine(state)}`),
232
- ];
215
+ const right = [clipRight(title), clipRight(statusLine(state))];
233
216
  if (state.me && !state.keyInvalid) {
234
- content.push(clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`));
235
- }
236
- else {
237
- content.push(clip(mark(1)));
217
+ right.push(clipRight(usageLine(state.me, rightCells, 0)));
238
218
  }
239
- const room = contentCells - indent;
240
- const blurb = tagline(room);
241
- const tips = tipsLine(room);
242
- const fresh = whatsNewLine(state.version, room);
219
+ const blurb = tagline(rightCells);
220
+ const tips = tipsLine(rightCells);
221
+ const fresh = whatsNewLine(state.version, rightCells);
243
222
  if (blurb)
244
- content.push(clip(`${pad}${blurb}`));
223
+ right.push(clipRight(blurb));
245
224
  if (tips)
246
- content.push(clip(`${pad}${tips}`));
225
+ right.push(clipRight(tips));
247
226
  if (fresh)
248
- content.push(clip(`${pad}${fresh}`));
249
- if (boxed && density === "full") {
250
- content.splice(3, 0, "");
251
- return [...roundedBox(content, { width: outer, padding: 1 })];
252
- }
227
+ right.push(clipRight(fresh));
228
+ const markCol = markLines.map((line) => padVisible(paintSpriteRow(line), markWidth));
229
+ const content = sideBySide(markCol, density === "nano" ? right.slice(0, 3) : right, markWidth, gap).map(clip);
230
+ if (density === "mini" || density === "nano")
231
+ return content;
253
232
  if (boxed) {
254
233
  return [...roundedBox(content, { width: outer, padding: 1 })];
255
234
  }
@@ -285,7 +264,7 @@ export async function printBannerHeader(opts) {
285
264
  }
286
265
  }
287
266
  return {
288
- rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
267
+ rows: (columns, variant, frame) => buildBannerRows(state, columns, { density: densityFromVariant(variant), frame }),
289
268
  };
290
269
  }
291
270
  export function shellPrompt(_loggedIn) {
package/dist/ui/logo.d.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
2
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
3
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
4
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
5
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
6
  */
5
- export declare const STORM_MARK: string[];
6
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
7
- export declare const STORM_MARK_WIDTH: number;
8
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
9
7
  export declare const TORNADO_LOGO: string[];
10
- /** @deprecated Alias. */
8
+ /** Single idle frame. Kept for callers that index frames. */
9
+ export declare const TORNADO_FRAMES: string[][];
10
+ /** Every sprite glyph is one column. */
11
11
  export declare const TORNADO_LOGO_WIDTH: number;
12
- /** @deprecated Compact home is the full two-row mark. */
13
12
  export declare const TORNADO_LOGO_COMPACT: string[];
13
+ /** @deprecated Use {@link TORNADO_LOGO}. */
14
+ export declare const STORM_MARK: string[];
15
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
16
+ export declare const STORM_MARK_WIDTH: number;
17
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
18
+ export declare function paintSpriteRow(row: string): string;
package/dist/ui/logo.js CHANGED
@@ -1,16 +1,28 @@
1
+ import { ansi } from "./ansi.js";
1
2
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
3
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
4
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
5
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
6
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
7
  */
5
- export const STORM_MARK = [
6
- "▟██▛",
7
- " ▜▙",
8
+ export const TORNADO_LOGO = [
9
+ " ▀▀▀▀▀▀▀▀▀▀▘",
10
+ "▀▀▀▀▀▀▀▀▀ ",
11
+ " ▀▀▀▀▀▀ ",
12
+ " ▝▀▀▀▀▀ ",
13
+ " ▝▀▀▘ ",
14
+ " ▀ ",
8
15
  ];
9
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
10
- export const STORM_MARK_WIDTH = Math.max(...STORM_MARK.map((line) => line.length));
11
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
12
- export const TORNADO_LOGO = STORM_MARK;
13
- /** @deprecated Alias. */
14
- export const TORNADO_LOGO_WIDTH = STORM_MARK_WIDTH;
15
- /** @deprecated Compact home is the full two-row mark. */
16
- export const TORNADO_LOGO_COMPACT = STORM_MARK;
16
+ /** Single idle frame. Kept for callers that index frames. */
17
+ export const TORNADO_FRAMES = [TORNADO_LOGO];
18
+ /** Every sprite glyph is one column. */
19
+ export const TORNADO_LOGO_WIDTH = Math.max(...TORNADO_LOGO.map((line) => [...line].length));
20
+ export const TORNADO_LOGO_COMPACT = TORNADO_LOGO;
21
+ /** @deprecated Use {@link TORNADO_LOGO}. */
22
+ export const STORM_MARK = TORNADO_LOGO;
23
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
24
+ export const STORM_MARK_WIDTH = TORNADO_LOGO_WIDTH;
25
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
26
+ export function paintSpriteRow(row) {
27
+ return row.replace(/[^ ]+/g, (run) => ansi.brightGreen(run));
28
+ }
@@ -23,7 +23,7 @@ export interface AskLineOptions {
23
23
  * resize cannot desync CUU counts. Pass `true` for compact, `"mini"` /
24
24
  * `"nano"` for a short pane that still shows the mark.
25
25
  */
26
- header?: (columns?: number, variant?: boolean | "mini" | "nano") => string[];
26
+ header?: (columns?: number, variant?: boolean | "mini" | "nano", frame?: number) => string[];
27
27
  }
28
28
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
29
29
  export declare class PromptClosedError extends Error {
@@ -47,6 +47,16 @@ export declare function promptClearOverlay(boxed: boolean): string;
47
47
  * frame's row count is unknowable after the terminal rewraps.
48
48
  */
49
49
  export declare const HEADER_REDRAW_PREFIX = "\u001B[H\u001B[0J";
50
+ export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
51
+ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
52
+ export type SpriteRect = {
53
+ col: number;
54
+ row: number;
55
+ width: number;
56
+ height: number;
57
+ };
58
+ /** SGR coordinates and the rectangle are both 1-based. */
59
+ export declare function hitTestSprite(col: number, row: number, rect: SpriteRect): boolean;
50
60
  export declare const DROPDOWN_MAX_COMMAND_ROWS = 8;
51
61
  export declare function formatSlashLabel(spec: CommandSpec): string;
52
62
  export declare function specMatchesTerm(spec: CommandSpec, term: string): boolean;
package/dist/ui/prompt.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { StringDecoder } from "node:string_decoder";
1
3
  import * as readline from "node:readline";
2
4
  import { createInterface } from "node:readline/promises";
3
5
  import { stdin, stdout } from "node:process";
@@ -37,6 +39,13 @@ export function promptClearOverlay(boxed) {
37
39
  * frame's row count is unknowable after the terminal rewraps.
38
40
  */
39
41
  export const HEADER_REDRAW_PREFIX = `\u001b[H${clearDown}`;
42
+ export const MOUSE_ENABLE = "\u001b[?1000h\u001b[?1006h";
43
+ export const MOUSE_DISABLE = "\u001b[?1000l\u001b[?1006l";
44
+ /** SGR coordinates and the rectangle are both 1-based. */
45
+ export function hitTestSprite(col, row, rect) {
46
+ return col >= rect.col && col < rect.col + rect.width &&
47
+ row >= rect.row && row < rect.row + rect.height;
48
+ }
40
49
  export const DROPDOWN_MAX_COMMAND_ROWS = 8;
41
50
  export function formatSlashLabel(spec) {
42
51
  const extra = spec.aliases?.length
@@ -171,9 +180,10 @@ export function fitHomeFrame(params) {
171
180
  : params.miniHeader?.length
172
181
  ? params.miniHeader
173
182
  : [];
183
+ const header = fallback.slice(0, Math.max(0, rows - 1));
174
184
  return {
175
- header: fallback,
176
- reserve: params.dockBottom ? Math.max(0, rows - fallback.length - 1) : 0,
185
+ header,
186
+ reserve: params.dockBottom ? Math.max(0, rows - header.length - 1) : 0,
177
187
  boxed: false,
178
188
  hasPrompt: false,
179
189
  hasHint: false,
@@ -338,7 +348,72 @@ export async function askLine(opts) {
338
348
  let exitHint = false;
339
349
  const ctrlCExit = new CtrlCExitGate();
340
350
  const wasRaw = Boolean(input.isRaw);
341
- readline.emitKeypressEvents(input);
351
+ // Decode keys from a private stream after removing mouse reports. Readline
352
+ // otherwise emits the numeric tail of SGR reports as ordinary typing.
353
+ // Never listen for `keypress` on `input` itself: once any TUI has called
354
+ // emitKeypressEvents(stdin), stdin decodes keys too and every character
355
+ // (and every mouse report) would land in the buffer twice.
356
+ const keys = new PassThrough();
357
+ const decoder = new StringDecoder("utf8");
358
+ let pending = "";
359
+ let escapeTimer;
360
+ readline.emitKeypressEvents(keys);
361
+ keys.on("keypress", onKeypress);
362
+ function onData(chunk) {
363
+ if (finished)
364
+ return;
365
+ if (escapeTimer)
366
+ clearTimeout(escapeTimer);
367
+ pending += typeof chunk === "string" ? chunk : decoder.write(chunk);
368
+ while (pending && !finished) {
369
+ const start = pending.indexOf("\u001b");
370
+ if (start !== 0) {
371
+ const end = start < 0 ? pending.length : start;
372
+ const text = pending.slice(0, end);
373
+ pending = pending.slice(end);
374
+ keys.write(text);
375
+ continue;
376
+ }
377
+ if (pending.startsWith("\u001b[M")) {
378
+ if (pending.length < 6)
379
+ return;
380
+ pending = pending.slice(6);
381
+ continue;
382
+ }
383
+ const mouse = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])/.exec(pending);
384
+ if (mouse) {
385
+ pending = pending.slice(mouse[0].length);
386
+ continue;
387
+ }
388
+ const partialMouse = /^\u001b\[<\d*(?:;\d*){0,2}/.exec(pending);
389
+ if (partialMouse && pending.length > partialMouse[0].length) {
390
+ pending = pending.slice(partialMouse[0].length);
391
+ continue;
392
+ }
393
+ const csi = /^\u001b\[[0-?]*[ -/]*[@-~]/.exec(pending);
394
+ const ss3 = /^\u001bO[ -~]/.exec(pending);
395
+ if (csi || ss3) {
396
+ const sequence = csi?.[0] ?? ss3[0];
397
+ pending = pending.slice(sequence.length);
398
+ keys.write(sequence);
399
+ continue;
400
+ }
401
+ if (pending === "\u001b" ||
402
+ /^\u001b(?:\[[0-?]*[ -/]*|O?)$/.test(pending)) {
403
+ // Keep partial mouse reports across chunks. A lone Escape still works.
404
+ if (!pending.startsWith("\u001b[<")) {
405
+ escapeTimer = setTimeout(() => {
406
+ const text = pending;
407
+ pending = "";
408
+ keys.write(text);
409
+ }, 50);
410
+ }
411
+ return;
412
+ }
413
+ keys.write(pending[0]);
414
+ pending = pending.slice(1);
415
+ }
416
+ }
342
417
  input.setRawMode(true);
343
418
  input.resume();
344
419
  function currentDropdown() {
@@ -408,9 +483,8 @@ export async function askLine(opts) {
408
483
  // Never write more rows than the pane — that scrolls the welcome
409
484
  // tail (tips / what's new) into view and looks like a broken frame.
410
485
  if (frame.rows.length > roomRows) {
411
- const keep = frame.rows.slice(frame.rows.length - roomRows);
412
- const shift = frame.rows.length - keep.length;
413
- frame.rows = keep;
486
+ const shift = frame.rows.length - roomRows;
487
+ frame.rows = frame.rows.slice(shift);
414
488
  frame.inputRow = Math.max(0, frame.inputRow - shift);
415
489
  }
416
490
  // Home screen owns the pane: always origin + ED 0. Relative CUU after
@@ -442,7 +516,11 @@ export async function askLine(opts) {
442
516
  paint();
443
517
  }
444
518
  function cleanup() {
445
- input.removeListener("keypress", onKeypress);
519
+ if (escapeTimer)
520
+ clearTimeout(escapeTimer);
521
+ input.removeListener("data", onData);
522
+ keys.removeListener("keypress", onKeypress);
523
+ keys.destroy();
446
524
  output.removeListener("resize", onResize);
447
525
  input.setRawMode(wasRaw);
448
526
  // emitKeypressEvents leaves stdin flowing; pause it so an idle shell
@@ -599,8 +677,13 @@ export async function askLine(opts) {
599
677
  finish(null, err);
600
678
  }
601
679
  }
602
- input.on("keypress", onKeypress);
680
+ input.on("data", onData);
603
681
  output.on("resize", onResize);
604
- render();
682
+ try {
683
+ render();
684
+ }
685
+ catch (err) {
686
+ finish(null, err);
687
+ }
605
688
  });
606
689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
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",
@@ -7,6 +7,10 @@ description: Watch stacks explicitly named by the human while Auto land is on, a
7
7
 
8
8
  Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
9
9
 
10
+ ## Not done until landed
11
+
12
+ A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop.
13
+
10
14
  ## Scope and enrollment
11
15
 
12
16
  Watch only stacks the human named. If no unambiguous stack ID is available, ask the human to identify it; do not discover and enroll other stacks. Keep a separate cursor for each named stack.
@@ -15,11 +19,11 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
15
19
 
16
20
  ## Watch and patch
17
21
 
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. 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
- 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. 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[]`.
22
+ 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.
23
+ 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, 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.
24
+ 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. `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. 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), or a Draft PR. 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, 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.
25
+ 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. 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-*`.
26
+ 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[]`.
23
27
  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
28
  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
29
 
@@ -48,6 +48,11 @@ mg skill install --claude --cursor --agents
48
48
  - Prefer the smallest correct patch. Do not refactor around a finding.
49
49
  - Patch concrete bugs. A chat-only explanation is not a dismiss.
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
+ - If you verified a finding is wrong (not merely skipped) and it blocks the PR, for example a seam finding holding `Seam findings`, record the dismissal with `review_dismiss` (CLI: `mg dismiss`):
52
+ - First call it with `preview: true`, the PR's live `head_sha` and the review id (review_id) of the Vortex review that raised the finding. It lists that review's finding ids and the seam gate and writes nothing.
53
+ - Then call it with the `finding_ids` you checked (or `scope: "review"` when you checked every finding of that review), a `reason` that says why each is wrong, and an `evidence_url` when you have one.
54
+ - A refusal (stale_head, review_mismatch, unknown_finding, forbidden) means nothing was written. Re-read the pass at the live head; do not retry with another review's ids.
55
+ - The seam gate clears only when every finding of that seam review is dismissed. The result's `gate` says whether it did; CI, other reviews and Auto land are unchanged. For a stack PR, follow `watch.next` (the next `stack_wait` call).
51
56
  6. Patch on the PR head branch only.
52
57
  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
58
  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.
@@ -79,7 +84,8 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
79
84
  - patch a pass whose `head_sha` is not the commit you have checked out
80
85
  - wrap the CLI in a shell escape hatch
81
86
  - claim a finding is fixed without a diff that touches its path
82
- - claim a finding is dismissed without posting a `mergestorm-loop: dismiss` comment on the PR
87
+ - claim a finding is dismissed without posting a `mergestorm-loop: dismiss` comment on the PR or a `review_dismiss` result that lists it
88
+ - dismiss a finding you did not verify, one a human must decide (Decision required, policy fork), or a finding of a newer review than the one you checked
83
89
 
84
90
  ## Fallback (no MCP)
85
91