mergestorm 0.3.29 → 0.3.31

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.29 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.31 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -114,7 +114,7 @@ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --
114
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
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
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.
117
+ - `--json` writes exactly one JSON document to stdout: the final `mergestorm.stack_watch/v1` envelope, or `{ stackId, outcome }` when there is none (a repeated failed read, `--max` timeout, or abort). The MS-WATCH marker lines go to stderr, so do not merge the streams (2>&1) before parsing stdout.
118
118
 
119
119
  ```bash
120
120
  mg stack watch 11111111-1111-4111-8111-111111111111 --head "$(git rev-parse HEAD)"
@@ -129,7 +129,7 @@ Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it
129
129
  - Retrying the same dismissal is safe; it reports the earlier record.
130
130
  - The seam gate clears only when every finding of that integration review is dismissed and the review is still the current seam verdict. CI, other reviews and Auto land policy still apply.
131
131
  - Vortex does not raise a dismissed finding again on the same diff (the same head, or a restack with an unchanged diff).
132
- - With `--json`, the result carries `gate` and, for a stack PR, `watch.next` (the next `stack_wait` call).
132
+ - With `--json`, the result carries `gate` and, for a stack PR, `watch.next` (the next `stack_wait` call, plus `watch.next.background`, the `mg stack watch` command to run in the background instead).
133
133
 
134
134
  `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. On review-unit stacks, `stack land` **promotes** the tip into the unit (same gates as the dashboard); otherwise it lands the bottom open PR. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`). Unattended landing is `mg queue add`.
135
135
 
package/dist/api.d.ts CHANGED
@@ -29,6 +29,13 @@ export declare function devicePost(base: string, route: string, json: unknown, o
29
29
  status: number;
30
30
  body: any;
31
31
  }>;
32
+ export type SurgeUsage = {
33
+ includedMinutes: number;
34
+ usedMinutes: number;
35
+ walletMinutes: number;
36
+ windowStart: string;
37
+ pricePerMinuteUsd: number;
38
+ };
32
39
  export type MeResponse = {
33
40
  key: {
34
41
  prefix: string;
@@ -50,6 +57,7 @@ export type MeResponse = {
50
57
  bonus?: {
51
58
  remaining: number;
52
59
  };
60
+ surge?: SurgeUsage;
53
61
  };
54
62
  };
55
63
  export type JobListItem = {
@@ -8,4 +8,4 @@ export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
10
  export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
11
- export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
11
+ export { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackWatchBackgroundCommand, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
@@ -8,4 +8,4 @@ export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
10
  export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, } from "../stack-blockers.js";
11
- export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
11
+ export { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackWatchBackgroundCommand, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
@@ -221,6 +221,7 @@ export async function openTabsBrowser(initial) {
221
221
  used: me.usage.standard.used,
222
222
  limit: me.usage.standard.limit,
223
223
  bonusRemaining: me.usage.bonus?.remaining,
224
+ surge: me.usage.surge,
224
225
  resetsAt: me.resets_at,
225
226
  jobs: jobs.slice(0, 5).map(toPanelJob),
226
227
  jobsError: jobsError ?? undefined,
@@ -37,6 +37,7 @@ export async function cmdCredits(args) {
37
37
  used: s.used,
38
38
  limit: s.limit,
39
39
  bonusRemaining: me.usage.bonus?.remaining,
40
+ surge: me.usage.surge,
40
41
  resetsAt: me.resets_at,
41
42
  jobs: recentJobs.map(toPanelJob),
42
43
  columns,
@@ -120,7 +120,7 @@ export function formatDismissResult(result, watch) {
120
120
  : `Seam gate: not blocking (seam_state=${seam.state}).`);
121
121
  }
122
122
  if (watch?.next)
123
- lines.push(`Next: ${watch.next.command}`);
123
+ lines.push(`Next: ${watch.next.background} as a background command, or ${watch.next.command}`);
124
124
  return lines.join("\n");
125
125
  }
126
126
  export async function cmdDismiss(args, deps = {}) {
@@ -1,5 +1,5 @@
1
1
  import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
2
- import { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
2
+ import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
3
3
  import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
@@ -36,7 +36,10 @@ export function stackWatchTextLines(watch) {
36
36
  return [watch.message];
37
37
  const next = watch.next;
38
38
  return [
39
- ...(next ? [`Next: ${next.command} (MCP: stack_wait ${JSON.stringify(next.args)})`] : []),
39
+ ...(next ? [
40
+ `Next: ${next.background} as a background command (notify on ${STACK_WATCH_NOTIFY_PATTERN}, then end your turn)`,
41
+ `Or poll: ${next.command} (MCP: stack_wait ${JSON.stringify(next.args)})`,
42
+ ] : []),
40
43
  STACK_WATCH_NOT_DONE_SENTENCE,
41
44
  ];
42
45
  }
@@ -56,6 +59,7 @@ const STACK_USAGE = `usage:
56
59
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
57
60
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or a repeated failed read; prints
58
61
  MS-WATCH LANDED and exits 0 once the watch is done. Run it as a background command.
62
+ --json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
59
63
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
60
64
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
61
65
  mergestorm stack restack <stack-id> [--json]
@@ -392,6 +392,11 @@ export type MergeQueueBounceDetail = {
392
392
  conflictPaths?: string[];
393
393
  message?: string;
394
394
  warning?: string;
395
+ batch?: MergeQueueBounceBatch;
396
+ };
397
+ export type MergeQueueBounceBatch = {
398
+ id: string;
399
+ withPrNumbers: number[];
395
400
  };
396
401
  export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
397
402
  export type MergeQueueEntryBatchDto = {
package/dist/stack-dto.js CHANGED
@@ -84,7 +84,11 @@ export function mergeQueueBounceLabel(entry) {
84
84
  (detail?.conflictPaths?.length ? detail.conflictPaths.join(", ") : null) ??
85
85
  detail?.message ??
86
86
  (detail?.prNumber != null ? `PR #${detail.prNumber}` : null);
87
- return specifics ? `${kindLabel} — ${specifics}` : kindLabel;
87
+ const withPrNumbers = detail?.batch?.withPrNumbers ?? [];
88
+ const batch = withPrNumbers.length
89
+ ? ` in a red batch with ${withPrNumbers.map((prNumber) => `#${prNumber}`).join(", ")}`
90
+ : "";
91
+ return specifics ? `${kindLabel} — ${specifics}${batch}` : `${kindLabel}${batch}`;
88
92
  }
89
93
  return entry.bounceReason?.trim() || "bounced";
90
94
  }
@@ -20,6 +20,7 @@ export type RunStackWatchOptions = {
20
20
  cursor?: StackWatchCursor;
21
21
  signal?: AbortSignal;
22
22
  write?: (line: string) => void;
23
+ writeMarker?: (line: string) => void;
23
24
  poll?: typeof pollStackWatch;
24
25
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
25
26
  now?: () => number;
@@ -29,14 +29,15 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
29
29
  const until = opts.until ?? "attention";
30
30
  const ignore = opts.ignore ?? [];
31
31
  const write = opts.write ?? ((line) => console.log(line));
32
+ const writeMarker = opts.json ? opts.writeMarker ?? ((line) => console.error(line)) : write;
32
33
  const poll = opts.poll ?? pollStackWatch;
33
34
  const now = opts.now ?? Date.now;
34
35
  const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
35
36
  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));
37
+ const emit = (line, envelope, outcome) => {
38
+ writeMarker(line);
39
+ if (opts.json && outcome)
40
+ write(JSON.stringify(envelope ?? { stackId: id, outcome }));
40
41
  };
41
42
  const pause = async (ms) => {
42
43
  const remaining = Math.min(ms, Math.max(0, deadline - now()));
@@ -48,7 +49,7 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
48
49
  const ignoredKeys = new Map();
49
50
  let reportedKey = null;
50
51
  const landed = (envelope) => {
51
- emit(stackWatchLandedLine(envelope), envelope);
52
+ emit(stackWatchLandedLine(envelope), envelope, "landed");
52
53
  if (!opts.json)
53
54
  write(envelope.watch.message);
54
55
  return { outcome: "landed", exitCode: STACK_WATCH_EXIT.landed, envelope };
@@ -56,7 +57,7 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
56
57
  const failed = async (message, envelope) => {
57
58
  failures += 1;
58
59
  if (failures >= 2) {
59
- emit(stackWatchFailedLine(id, message), envelope);
60
+ emit(stackWatchFailedLine(id, message), envelope, "failed");
60
61
  return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
61
62
  }
62
63
  await pause(STACK_WATCH_FAILED_RETRY_MS);
@@ -106,7 +107,7 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
106
107
  }
107
108
  const silent = [...ignoredKeys.values()].includes(key) || key === reportedKey;
108
109
  if (!silent) {
109
- emit(stackWatchAttentionLine(envelope), envelope);
110
+ emit(stackWatchAttentionLine(envelope), envelope, until === "attention" ? "attention" : null);
110
111
  if (until === "attention")
111
112
  return { outcome: "attention", exitCode: STACK_WATCH_EXIT.attention, envelope };
112
113
  reportedKey = key;
@@ -116,10 +117,13 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
116
117
  }
117
118
  }
118
119
  catch (err) {
119
- if (isAbort(err, opts.signal))
120
+ if (isAbort(err, opts.signal)) {
121
+ if (opts.json)
122
+ write(JSON.stringify({ stackId: id, outcome: "aborted" }));
120
123
  return { outcome: "aborted", exitCode: 130, envelope: null };
124
+ }
121
125
  throw err;
122
126
  }
123
- emit(stackWatchTimeoutLine(id, opts.maxMs ?? 0), null);
127
+ emit(stackWatchTimeoutLine(id, opts.maxMs ?? 0), null, "timeout");
124
128
  return { outcome: "timeout", exitCode: STACK_WATCH_EXIT.timeout, envelope: null };
125
129
  }
@@ -1,5 +1,7 @@
1
1
  import type { StackDto } from "./stack-dto.js";
2
2
  export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
3
+ export declare const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
4
+ export declare function stackWatchBackgroundCommand(stackId: string): string;
3
5
  export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
4
6
  export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
5
7
  export type StackWatchNextArgs = {
@@ -13,6 +15,7 @@ export type StackWatchNext = {
13
15
  tool: "stack_wait";
14
16
  args: StackWatchNextArgs;
15
17
  command: string;
18
+ background: string;
16
19
  };
17
20
  export type StackWatchObligation = {
18
21
  done: boolean;
@@ -1,4 +1,8 @@
1
1
  export const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
2
+ export const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
3
+ export function stackWatchBackgroundCommand(stackId) {
4
+ return `mg stack watch ${stackId}`;
5
+ }
2
6
  const DONE_MESSAGES = {
3
7
  landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
4
8
  closed: "Every PR in this stack is merged or closed and nothing is left to land, but not every PR landed. The watch is done; tell the human which PRs were closed without landing.",
@@ -49,8 +53,10 @@ export function stackWatchObligation(input) {
49
53
  : input.status === "failed"
50
54
  ? "failed"
51
55
  : input.attention ? "attention" : input.unread ? "unread" : "open";
56
+ const background = stackWatchBackgroundCommand(input.stackId);
52
57
  const parts = [
53
58
  STACK_WATCH_NOT_DONE_SENTENCE,
59
+ `If you cannot hold a long turn open, run \`${background}\` as a background command instead, notify on output matching ${STACK_WATCH_NOTIFY_PATTERN}, and end your turn: it keeps calling stack_wait for you, stays silent while the stack waits, and wakes you on attention or when the watch is done.`,
54
60
  `Watch stack ${input.stackId} as a whole until it lands or the human takes it back. A clean push, a submit, or one merged layer does not finish it: merging a lower layer can break a layer above it.`,
55
61
  ];
56
62
  if (reason === "rate_limited") {
@@ -60,7 +66,7 @@ export function stackWatchObligation(input) {
60
66
  parts.push(`This read failed, which does not end the task. Call stack_wait once more with the same cursor; if that fails too, tell the human that stack ${input.stackId} is not landed and is no longer being watched.`);
61
67
  }
62
68
  else if (reason === "attention" && input.attention) {
63
- parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed.`);
69
+ parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed, or start \`${background} --head <pushed-sha>\` in the background.`);
64
70
  }
65
71
  else if (reason === "unread") {
66
72
  parts.push("This tool did not read the stack's state; stack_wait will.");
@@ -73,7 +79,7 @@ export function stackWatchObligation(input) {
73
79
  done: false,
74
80
  until: "landed",
75
81
  reason,
76
- next: { tool: "stack_wait", args, command: `mg stack wait ${input.stackId} --json` },
82
+ next: { tool: "stack_wait", args, command: `mg stack wait ${input.stackId} --json`, background },
77
83
  message: parts.join(" "),
78
84
  };
79
85
  }
package/dist/ui/banner.js CHANGED
@@ -120,6 +120,12 @@ const WHATS_NEW = {
120
120
  "0.3.29": [
121
121
  "mg stack watch waits in the background",
122
122
  ],
123
+ "0.3.30": [
124
+ "stack watch --json prints one JSON document",
125
+ ],
126
+ "0.3.31": [
127
+ "every stack Next line offers mg stack watch",
128
+ ],
123
129
  };
124
130
  function densityFromVariant(variant) {
125
131
  if (variant === "mini" || variant === "nano")
@@ -24,6 +24,12 @@ export declare function formatResetLabel(iso: string | null | undefined, now?: D
24
24
  export declare function formatDaysLeft(iso: string | null | undefined, now?: Date): string;
25
25
  /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
26
26
  export declare function usageBarWidth(columns: number): number;
27
+ export type SurgeUsageSummary = {
28
+ includedMinutes: number;
29
+ usedMinutes: number;
30
+ walletMinutes: number;
31
+ };
32
+ export declare function formatSurgeSummary(surge: SurgeUsageSummary): string;
27
33
  export type UsagePanelJob = {
28
34
  job: string;
29
35
  verdict: string;
@@ -37,6 +43,7 @@ export type UsagePanelInput = {
37
43
  used: number;
38
44
  limit: number | null;
39
45
  bonusRemaining?: number;
46
+ surge?: SurgeUsageSummary;
40
47
  resetsAt: string | null | undefined;
41
48
  jobs: UsagePanelJob[];
42
49
  /** When the jobs fetch failed, show this instead of "No review jobs yet." */
package/dist/ui/usage.js CHANGED
@@ -87,6 +87,16 @@ export function formatDaysLeft(iso, now = new Date()) {
87
87
  export function usageBarWidth(columns) {
88
88
  return Math.max(8, frameWidth(80, columns) - 13);
89
89
  }
90
+ function formatMinutes(value) {
91
+ return value.toLocaleString("en-US", { maximumFractionDigits: 1 });
92
+ }
93
+ export function formatSurgeSummary(surge) {
94
+ const wallet = `wallet ${formatMinutes(surge.walletMinutes)} min`;
95
+ if (surge.includedMinutes <= 0)
96
+ return `Surge: ${wallet}`;
97
+ return (`Surge: ${formatMinutes(surge.usedMinutes)} of ${formatMinutes(surge.includedMinutes)} included min this period` +
98
+ ` · ${wallet}`);
99
+ }
90
100
  function clipLine(line, columns) {
91
101
  if (visibleWidth(line) <= columns)
92
102
  return line;
@@ -111,6 +121,7 @@ export function formatUsagePanel(input) {
111
121
  ...(input.bonusRemaining == null
112
122
  ? []
113
123
  : [` Bonus credits: ${input.bonusRemaining} remaining`]),
124
+ ...(input.surge ? [` ${formatSurgeSummary(input.surge)}`] : []),
114
125
  "",
115
126
  ];
116
127
  if (input.jobs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
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",
@@ -15,12 +15,12 @@ A stack watch is done only when the stack is landed, or when the human takes it
15
15
 
16
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
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.
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. Every `watch.next` carries this command as `watch.next.background`.
19
19
  2. It prints one line and exits for one of three reasons:
20
20
  - `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` (exit 3): the watch returned `attention`.
21
21
  - `MS-WATCH ATTENTION failed stack=<id> error="<message>"` (exit 3): a read failed and its one retry failed too.
22
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.
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` writes exactly one JSON document to stdout: the final `mergestorm.stack_watch/v1` envelope, or `{ stackId, outcome }` when there is no envelope (a repeated failed read, timeout, or abort). MS-WATCH marker lines go to stderr; do not merge the streams before parsing stdout.
24
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
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
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.