mergestorm 0.3.28 → 0.3.29

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.28 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.29 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -95,11 +95,31 @@ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
95
95
  mergestorm stack restack <stack-id> Restack descendants
96
96
  mergestorm stack land <stack-id> Land / promote (into review unit when present)
97
97
  mergestorm stack wait <stack-id> [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)
98
+ mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <text>] [--max <min>] [--head <sha>] [--json] Background stack watcher (see below)
98
99
  mergestorm stack reset --force Clear local authoring state (not branches/PRs)
99
100
  mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview List a Vortex review's finding ids and the seam gate
100
101
  mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --reason <text> Dismiss verified-wrong findings (`--all` for the whole review, `--evidence <url>`, `--json`)
101
102
  ```
102
103
 
104
+ ### Watching a stack in the background
105
+
106
+ `mg stack watch <stack-id>` is for agents that cannot hold a long turn open. Run it as a background command, have your host notify you on output matching `MS-WATCH (ATTENTION|LANDED)`, and end the turn. While it waits it spends no model tokens and talks only to the Mergestorm API, not GitHub.
107
+
108
+ - It calls `stack wait` in slices of at most 300s and carries the cursor (`enrolled_head_sha`, `after_finished_at`, `bounce_id`) from one slice to the next.
109
+ - It prints nothing while the stack is `waiting` or `in_progress`, including while Cyclone or Vortex holds the blocked PR (`busy[]`, `actAfter: "agents_idle"`).
110
+ - On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3.
111
+ - When the watch is done it prints `MS-WATCH LANDED stack=<id> reason=<reason>` and exits 0. Only `reason=landed` is a landed stack; `closed`, `archived` and `not_found` end the watch without a land.
112
+ - A failed read is retried once. If the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. A rate limit is waited out.
113
+ - `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
114
+ - `--ignore <text>` keeps it silent on the first attention whose blocker contains that text (case-insensitive), until the blocker, its PR or its head changes. Use it for a blocker a human has taken over. Repeat the flag for more than one.
115
+ - `--until landed` keeps running after an attention, printing each new attention once, and exits only when the watch is done or reads keep failing.
116
+ - `--max <minutes>` caps the whole watch; when it runs out it prints `MS-WATCH TIMEOUT` and exits 5. The stack is not landed then.
117
+ - `--json` adds the full `mergestorm.stack_watch/v1` envelope as one JSON line after each marker line.
118
+
119
+ ```bash
120
+ mg stack watch 11111111-1111-4111-8111-111111111111 --head "$(git rev-parse HEAD)"
121
+ ```
122
+
103
123
  ### Dismissing a finding
104
124
 
105
125
  Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it against the code. It is recorded server side with your GitHub login, the reason, the evidence URL, the scope and the time. Rules:
@@ -241,6 +241,7 @@ export const COMMAND_REGISTRY = [
241
241
  "mergestorm stack list [--json] List registered stacks",
242
242
  "mergestorm stack status <stack-id> [--json] Enriched stack status",
243
243
  "mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait). --json: one document on stdout; timeout notice on stderr, exit 5",
244
+ "mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json] Background watcher: silent while waiting; MS-WATCH ATTENTION exits 3, MS-WATCH LANDED exits 0",
244
245
  "mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json] Per-stack policy",
245
246
  "mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] Import an open PR chain",
246
247
  "mergestorm stack restack <stack-id> Restack stack descendants",
@@ -9,6 +9,7 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
9
9
  import { gitTopLevel } from "../repo-identity.js";
10
10
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
11
11
  import { pollStackWatch } from "../stack-watch.js";
12
+ import { runStackWatch } from "../stack-watch-loop.js";
12
13
  export declare function stackHeldLine(held: StackHeldBlocker | null | undefined): string[];
13
14
  export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
14
15
  export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
@@ -138,6 +139,11 @@ export declare function cmdStackWait(args: string[], deps?: {
138
139
  pollStackWatch?: typeof pollStackWatch;
139
140
  signal?: AbortSignal;
140
141
  }): Promise<void>;
142
+ export declare function cmdStackWatch(args: string[], deps?: {
143
+ loadConfig?: typeof loadConfig;
144
+ runStackWatch?: typeof runStackWatch;
145
+ signal?: AbortSignal;
146
+ }): Promise<void>;
141
147
  export declare function cmdStackStatus(args: string[], deps?: {
142
148
  loadConfig?: typeof loadConfig;
143
149
  getEnrichedStack?: typeof getEnrichedStack;
@@ -10,6 +10,7 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
10
10
  import { gitTopLevel } from "../repo-identity.js";
11
11
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
12
12
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
13
+ import { runStackWatch, STACK_WATCH_EXIT } from "../stack-watch-loop.js";
13
14
  import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
14
15
  import { ansi } from "../ui/ansi.js";
15
16
  import { runLineTabsBrowser } from "../ui/line-tabs.js";
@@ -51,6 +52,10 @@ const STACK_USAGE = `usage:
51
52
  mergestorm stack wait <stack-id> [--json] [--timeout <s>]
52
53
  --json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
53
54
  (exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
55
+ mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
56
+ Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
57
+ Prints one MS-WATCH ATTENTION line and exits 3 on attention or a repeated failed read; prints
58
+ MS-WATCH LANDED and exits 0 once the watch is done. Run it as a background command.
54
59
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
55
60
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
56
61
  mergestorm stack restack <stack-id> [--json]
@@ -926,6 +931,70 @@ export async function cmdStackWait(args, deps = {}) {
926
931
  throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
927
932
  }
928
933
  }
934
+ export async function cmdStackWatch(args, deps = {}) {
935
+ const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]";
936
+ const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
937
+ const positional = [];
938
+ const ignore = [];
939
+ let until = "attention";
940
+ let maxMinutes;
941
+ let head;
942
+ let json = false;
943
+ for (let i = 0; i < args.length; i++) {
944
+ const arg = args[i];
945
+ const flag = arg.startsWith("--") && arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
946
+ const value = () => {
947
+ const raw = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++i];
948
+ if (raw === undefined || !raw.trim())
949
+ throw fail(`${flag} needs a value`);
950
+ return raw.trim();
951
+ };
952
+ if (arg === "--json")
953
+ json = true;
954
+ else if (flag === "--until") {
955
+ const picked = value();
956
+ if (picked !== "attention" && picked !== "landed")
957
+ throw fail("--until must be attention or landed");
958
+ until = picked;
959
+ }
960
+ else if (flag === "--ignore")
961
+ ignore.push(value());
962
+ else if (flag === "--max") {
963
+ maxMinutes = Number(value());
964
+ if (!Number.isFinite(maxMinutes) || maxMinutes <= 0)
965
+ throw fail("--max must be a positive number of minutes");
966
+ }
967
+ else if (flag === "--head") {
968
+ head = value();
969
+ if (!/^[0-9a-f]{7,40}$/i.test(head))
970
+ throw fail("--head must be a commit SHA");
971
+ }
972
+ else if (arg.startsWith("-"))
973
+ throw fail();
974
+ else
975
+ positional.push(arg);
976
+ }
977
+ if (positional.length !== 1)
978
+ throw fail();
979
+ const stackId = requireStackId(positional[0], usage);
980
+ const cfg = await (deps.loadConfig ?? loadConfig)();
981
+ const result = await (deps.runStackWatch ?? runStackWatch)(cfg, stackId, {
982
+ until,
983
+ ignore,
984
+ json,
985
+ signal: deps.signal,
986
+ ...(maxMinutes !== undefined ? { maxMs: maxMinutes * 60_000 } : {}),
987
+ ...(head ? { cursor: { stackId: stackId.trim().toLowerCase(), enrolledHeadSha: head.toLowerCase() } } : {}),
988
+ });
989
+ if (result.exitCode === STACK_WATCH_EXIT.landed)
990
+ return;
991
+ const reason = result.outcome === "timeout"
992
+ ? `Stopped watching stack ${stackId} after --max; it is not landed.`
993
+ : result.outcome === "aborted"
994
+ ? `Stopped watching stack ${stackId}; it is not landed.`
995
+ : `Stack ${stackId} needs attention; it is not landed.`;
996
+ throw new CommandError(reason, result.exitCode, result.outcome === "timeout" ? "review_timeout" : "stack_attention");
997
+ }
929
998
  export async function cmdStackStatus(args, deps = {}) {
930
999
  const usage = "usage: mergestorm stack status <stack-id> [--json]";
931
1000
  const asJson = args.includes("--json");
@@ -1106,6 +1175,8 @@ export async function cmdStack(args, options = {}) {
1106
1175
  return cmdStackStatus(rest);
1107
1176
  if (sub === "wait")
1108
1177
  return cmdStackWait(rest, { signal: options.signal });
1178
+ if (sub === "watch")
1179
+ return cmdStackWatch(rest, { signal: options.signal });
1109
1180
  if (sub === "set")
1110
1181
  return cmdStackSet(rest);
1111
1182
  if (sub === "adopt")
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "busy" | "adopt_failed";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "busy" | "adopt_failed" | "stack_attention";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;
@@ -393,6 +393,14 @@ export type MergeQueueBounceDetail = {
393
393
  message?: string;
394
394
  warning?: string;
395
395
  };
396
+ export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
397
+ export type MergeQueueEntryBatchDto = {
398
+ id: string;
399
+ position: number;
400
+ size: number;
401
+ state: MergeQueueBatchState;
402
+ landedCount: number;
403
+ };
396
404
  export type MergeQueueEntryDto = {
397
405
  id: string;
398
406
  stackId: string;
@@ -414,5 +422,6 @@ export type MergeQueueEntryDto = {
414
422
  /** Target tip included in the verified PR head. Null until recorded. */
415
423
  verifyBaseSha: string | null;
416
424
  cancelRequestedAt?: string | null;
425
+ batch?: MergeQueueEntryBatchDto | null;
417
426
  finishedAt: string | null;
418
427
  };
@@ -0,0 +1,37 @@
1
+ import type { Config } from "./config.js";
2
+ import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
3
+ export declare const STACK_WATCH_MARKER = "MS-WATCH";
4
+ export declare const STACK_WATCH_EXIT: {
5
+ readonly landed: 0;
6
+ readonly attention: 3;
7
+ readonly timeout: 5;
8
+ };
9
+ export declare const STACK_WATCH_SLICE_MS = 300000;
10
+ export declare const STACK_WATCH_RECHECK_MS = 30000;
11
+ export declare const STACK_WATCH_FAILED_RETRY_MS = 15000;
12
+ export declare const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30000;
13
+ export type StackWatchUntil = "attention" | "landed";
14
+ export type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
15
+ export type RunStackWatchOptions = {
16
+ until?: StackWatchUntil;
17
+ ignore?: readonly string[];
18
+ maxMs?: number;
19
+ json?: boolean;
20
+ cursor?: StackWatchCursor;
21
+ signal?: AbortSignal;
22
+ write?: (line: string) => void;
23
+ poll?: typeof pollStackWatch;
24
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
25
+ now?: () => number;
26
+ };
27
+ export type StackWatchLoopResult = {
28
+ outcome: StackWatchOutcome;
29
+ exitCode: number;
30
+ envelope: StackWatchEnvelope | null;
31
+ };
32
+ export declare function stackWatchAttentionKey(envelope: Pick<StackWatchEnvelope, "prNumber" | "headSha" | "blocker">): string;
33
+ export declare function stackWatchAttentionLine(envelope: StackWatchEnvelope): string;
34
+ export declare function stackWatchLandedLine(envelope: StackWatchEnvelope): string;
35
+ export declare function stackWatchFailedLine(stackId: string, message: string): string;
36
+ export declare function stackWatchTimeoutLine(stackId: string, maxMs: number): string;
37
+ export declare function runStackWatch(cfg: Config, stackId: string, opts?: RunStackWatchOptions): Promise<StackWatchLoopResult>;
@@ -0,0 +1,125 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
3
+ export const STACK_WATCH_MARKER = "MS-WATCH";
4
+ export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
5
+ export const STACK_WATCH_SLICE_MS = 300_000;
6
+ export const STACK_WATCH_RECHECK_MS = 30_000;
7
+ export const STACK_WATCH_FAILED_RETRY_MS = 15_000;
8
+ export const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
9
+ export function stackWatchAttentionKey(envelope) {
10
+ return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${envelope.blocker ?? ""}`;
11
+ }
12
+ export function stackWatchAttentionLine(envelope) {
13
+ return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}`;
14
+ }
15
+ export function stackWatchLandedLine(envelope) {
16
+ return `${STACK_WATCH_MARKER} LANDED stack=${envelope.stackId} reason=${envelope.watch.reason}`;
17
+ }
18
+ export function stackWatchFailedLine(stackId, message) {
19
+ return `${STACK_WATCH_MARKER} ATTENTION failed stack=${stackId} error=${JSON.stringify(message)}`;
20
+ }
21
+ export function stackWatchTimeoutLine(stackId, maxMs) {
22
+ return `${STACK_WATCH_MARKER} TIMEOUT stack=${stackId} after=${Math.round(maxMs / 60_000)}m`;
23
+ }
24
+ function isAbort(err, signal) {
25
+ return signal?.aborted === true || (err instanceof Error && err.name === "AbortError");
26
+ }
27
+ export async function runStackWatch(cfg, stackId, opts = {}) {
28
+ const id = stackId.trim().toLowerCase();
29
+ const until = opts.until ?? "attention";
30
+ const ignore = opts.ignore ?? [];
31
+ const write = opts.write ?? ((line) => console.log(line));
32
+ const poll = opts.poll ?? pollStackWatch;
33
+ const now = opts.now ?? Date.now;
34
+ const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
35
+ const deadline = opts.maxMs !== undefined ? now() + opts.maxMs : Number.POSITIVE_INFINITY;
36
+ const emit = (line, envelope) => {
37
+ write(line);
38
+ if (opts.json && envelope)
39
+ write(JSON.stringify(envelope));
40
+ };
41
+ const pause = async (ms) => {
42
+ const remaining = Math.min(ms, Math.max(0, deadline - now()));
43
+ if (remaining > 0)
44
+ await wait(remaining, opts.signal);
45
+ };
46
+ let cursor = opts.cursor;
47
+ let failures = 0;
48
+ const ignoredKeys = new Map();
49
+ let reportedKey = null;
50
+ const landed = (envelope) => {
51
+ emit(stackWatchLandedLine(envelope), envelope);
52
+ if (!opts.json)
53
+ write(envelope.watch.message);
54
+ return { outcome: "landed", exitCode: STACK_WATCH_EXIT.landed, envelope };
55
+ };
56
+ const failed = async (message, envelope) => {
57
+ failures += 1;
58
+ if (failures >= 2) {
59
+ emit(stackWatchFailedLine(id, message), envelope);
60
+ return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
61
+ }
62
+ await pause(STACK_WATCH_FAILED_RETRY_MS);
63
+ return null;
64
+ };
65
+ try {
66
+ while (now() < deadline) {
67
+ let envelope;
68
+ try {
69
+ envelope = await poll(cfg, id, {
70
+ timeoutMs: Math.min(STACK_WATCH_SLICE_MS, Math.max(0, deadline - now())),
71
+ ...(cursor ? { cursor } : {}),
72
+ signal: opts.signal,
73
+ });
74
+ }
75
+ catch (err) {
76
+ if (isAbort(err, opts.signal))
77
+ throw err;
78
+ if (err instanceof StackWatchTimeoutError) {
79
+ cursor = err.lastEnvelope.cursor;
80
+ failures = 0;
81
+ continue;
82
+ }
83
+ if (err instanceof StackWatchError && err.lastEnvelope.watch.done)
84
+ return landed(err.lastEnvelope);
85
+ if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
86
+ await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
87
+ continue;
88
+ }
89
+ const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null);
90
+ if (result)
91
+ return result;
92
+ continue;
93
+ }
94
+ failures = 0;
95
+ cursor = envelope.cursor;
96
+ if (envelope.watch.done)
97
+ return landed(envelope);
98
+ if (envelope.status === "attention") {
99
+ const key = stackWatchAttentionKey(envelope);
100
+ const blocker = (envelope.blocker ?? "").toLowerCase();
101
+ for (const needle of ignore) {
102
+ const normalizedNeedle = needle.trim().toLowerCase();
103
+ if (normalizedNeedle && blocker.includes(normalizedNeedle) && !ignoredKeys.has(normalizedNeedle)) {
104
+ ignoredKeys.set(normalizedNeedle, key);
105
+ }
106
+ }
107
+ const silent = [...ignoredKeys.values()].includes(key) || key === reportedKey;
108
+ if (!silent) {
109
+ emit(stackWatchAttentionLine(envelope), envelope);
110
+ if (until === "attention")
111
+ return { outcome: "attention", exitCode: STACK_WATCH_EXIT.attention, envelope };
112
+ reportedKey = key;
113
+ }
114
+ }
115
+ await pause(STACK_WATCH_RECHECK_MS);
116
+ }
117
+ }
118
+ catch (err) {
119
+ if (isAbort(err, opts.signal))
120
+ return { outcome: "aborted", exitCode: 130, envelope: null };
121
+ throw err;
122
+ }
123
+ emit(stackWatchTimeoutLine(id, opts.maxMs ?? 0), null);
124
+ return { outcome: "timeout", exitCode: STACK_WATCH_EXIT.timeout, envelope: null };
125
+ }
package/dist/ui/banner.js CHANGED
@@ -117,6 +117,9 @@ const WHATS_NEW = {
117
117
  "0.3.28": [
118
118
  "stack watch reads the Auto land off reason",
119
119
  ],
120
+ "0.3.29": [
121
+ "mg stack watch waits in the background",
122
+ ],
120
123
  };
121
124
  function densityFromVariant(variant) {
122
125
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
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",
@@ -5,11 +5,25 @@ description: Watch stacks explicitly named by the human while Auto land is on, p
5
5
 
6
6
  # Mergestorm bounce watch
7
7
 
8
- Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
8
+ Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch, and the `mg stack watch` CLI command to wait in the background. Auto land owns landing.
9
9
 
10
10
  ## Not done until landed
11
11
 
12
- A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack, turns Auto land off, 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. A layer PR shown as merged is merged into `mg-stack-N`, not main: the stack is landed only when the land PR (head `mg-stack-N`) merges into main or `watch.done` says landed. Never judge landing from GitHub PR state alone.
12
+ A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack, turns Auto land off, 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. If your host cannot hold a long turn open, the background watcher in "Waiting in the background" does that waiting for you. `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. A layer PR shown as merged is merged into `mg-stack-N`, not main: the stack is landed only when the land PR (head `mg-stack-N`) merges into main or `watch.done` says landed. Never judge landing from GitHub PR state alone.
13
+
14
+ ## Waiting in the background
15
+
16
+ If your host cannot hold a long turn open (Cursor, Claude Code, Codex, and other turn-based agents), do not poll `stack_wait` inside your turn and do not subscribe to GitHub events. Hand the waiting to the CLI watcher:
17
+
18
+ 1. Run `mg stack watch <stack-id>` as a background command (add `--head <sha>` right after you pushed that SHA) with a notification on output matching `MS-WATCH (ATTENTION|LANDED)`, then end your turn. The watcher runs the same stack wait against the Mergestorm API in slices of at most 300s, carries the cursor (`enrolled_head_sha`, `after_finished_at`, `bounce_id`) from one slice to the next, and prints nothing while the stack is `waiting` or `in_progress`, including `busy[]` holds and `actAfter: "agents_idle"`. It spends no model tokens while it waits. Ending your turn while it runs is not ending the task.
19
+ 2. It prints one line and exits for one of three reasons:
20
+ - `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` (exit 3): the watch returned `attention`.
21
+ - `MS-WATCH ATTENTION failed stack=<id> error="<message>"` (exit 3): a read failed and its one retry failed too.
22
+ - `MS-WATCH LANDED stack=<id> reason=<reason>` (exit 0): `watch.done` is `true`. Only `reason=landed` is a landed stack; for `closed`, `archived`, or `not_found`, follow "Not done until landed".
23
+ With `--max <minutes>` it can also stop with `MS-WATCH TIMEOUT` (exit 5). The stack is not landed then; start the watcher again. `--json` adds the full envelope as one JSON line after the marker line.
24
+ 3. When it wakes you on `MS-WATCH ATTENTION pr=`, call `stack_status` once for that stack, then fix the blocker as steps 3 to 7 of "Watch and patch" say, push, and start the watcher again with `--head` set to the SHA you pushed. On `MS-WATCH ATTENTION failed`, the watcher already retried once: tell the human the stack is not landed and is no longer being watched.
25
+ 4. When the human has taken one blocker over and asked you to keep watching the rest, pass `--ignore <blocker-substring>`. The watcher stays silent on the first attention whose blocker contains that text until the blocker, its PR, or its head changes; any other attention still wakes you.
26
+ 5. `--until landed` keeps the watcher running after an attention: it prints each new attention line once and exits only when the watch is done or a read fails twice. Use it only when your host streams output lines to you while the command runs.
13
27
 
14
28
  ## Scope and enrollment
15
29
 
@@ -20,12 +34,12 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
20
34
  ## Watch and patch
21
35
 
22
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.
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.
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.
24
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. `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.
25
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-*`.
26
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.
27
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.
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 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.
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.
29
43
 
30
44
  ## Re-arm after a bounce
31
45