mergestorm 0.3.39 → 0.3.41

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.39 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.41 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -103,12 +103,12 @@ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --
103
103
 
104
104
  ### Watching a stack in the background
105
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.
106
+ `mg stack watch <stack-id>` is for agents that cannot hold a long turn open. Run it as a background command only after confirming that your host can resume the task on completion or output matching `MS-WATCH (ATTENTION|LANDED)`. The process monitors the stack; it does not itself resume a Codex, Claude Code, or Cursor conversation. Without a notification mechanism, keep using bounded `stack_wait` calls or explicitly report that automatic follow-up is unavailable. While it waits it spends no model tokens and talks only to the Mergestorm API, not GitHub.
107
107
 
108
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
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
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.
111
+ - A confirmed landing prints `MS-WATCH LANDED stack=<id> reason=landed` and exits 0. `closed`, `archived` and `not_found` end the watch with `MS-WATCH ATTENTION stack=<id> reason=<reason>` and exit 3. A missing stack does not prove a landing; verify each PR before reporting it merged.
112
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
113
  - `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
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.
@@ -137,7 +137,7 @@ Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it
137
137
 
138
138
  If the parent branch is already a layer of a **registered** (submitted) stack, `create` and `submit` refuse unless you pass **`--extend`**. That blocks accidentally gluing an unrelated PR onto an open unit. Growing a registered stack on purpose: `mg stack create --onto <tip> --extend` → commit → `mg stack submit --extend`.
139
139
 
140
- `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; adopt then moves the bottom of a 2+ PR stack onto `mg-stack-<n>` — leave it there; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push is always your local `git` credentials. PRs open as you when `gh` is installed and `gh api user` succeeds; otherwise `stack submit` asks the Mergestorm API (`POST /api/v1/stacks/pulls`) to open them as Cyclone on your behalf, after checking your GitHub account has write access, and assigns you. Without either, submit stops before pushing and links the Cyclone install page.
140
+ `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; adopt then moves the bottom of a 2+ PR stack onto `mg-stack-<n>` — leave it there; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push is always your local `git` credentials. PRs open as you when `gh` is installed and `gh api user` succeeds; otherwise `stack submit` asks the Mergestorm API (`POST /api/v1/stacks/pulls`) to open them as the Mergestorm Surge App on your behalf, after checking your GitHub account has write access, and assigns you. Stacks, the merge queue and Auto land run through Surge, and Cyclone is the auto-patch App. Without Surge, submit stops before pushing and prints the server's message, which names the App to install and links its install page. `mg settings` prints a `Stacks, merge queue, Auto land` line when the server reports whether that App is ready.
141
141
 
142
142
  Legacy repo-local `.mergestorm/stack.json` state migrates automatically on the next stack command. If local authoring state is stale or malformed, use `mg stack reset --force`; this clears only pre-submit CLI state and never deletes branches, PRs, or registered stacks.
143
143
 
package/dist/api.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Config } from "./config.js";
2
2
  import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, type BearerSettingsKey as SettingsWritableKey } from "./automation-catalog.js";
3
+ import { CommandError } from "./errors.js";
3
4
  import type { MergeQueueEntryDto, StackDto } from "./stack-dto.js";
4
- export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus, StackTempestStatus, StackUnitDto, StackUnitMemberDto, } from "./stack-dto.js";
5
+ export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus, StackUnitDto, StackUnitMemberDto, } from "./stack-dto.js";
5
6
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
6
7
  export declare const DEFAULT_API_TIMEOUT_MS = 30000;
7
8
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
@@ -172,6 +173,7 @@ export type SettingsPatch = Partial<import("./automation-catalog.js").BearerSett
172
173
  export type SettingsResponse = import("./automation-catalog.js").BearerSettingsValues & {
173
174
  cyclone_connected: boolean;
174
175
  github_connected: boolean;
176
+ stacks_ready?: boolean;
175
177
  };
176
178
  /**
177
179
  * Fetch Bearer /api/v1/settings; null on 404/network/timeouts so panels can
@@ -228,13 +230,16 @@ export type StackPolicyPatch = {
228
230
  export declare function stackPolicyBody(policy: StackPolicyPatch | undefined): StackPolicyPatch;
229
231
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
230
232
  export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config, policy?: StackPolicyPatch): Promise<unknown>;
233
+ export declare const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
234
+ export declare function surgeRefusal(body: unknown): CommandError | null;
235
+ export declare const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
231
236
  /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
232
237
  export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<{
233
238
  freezeBranch: string;
234
239
  created: boolean;
235
240
  }>;
236
- export declare const CYCLONE_INSTALL_URL = "https://github.com/apps/mergestorm-cyclone/installations/new";
237
- export declare function cycloneNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
241
+ export declare const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
242
+ export declare function surgeNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
238
243
  export declare function findStackPull(cfg: Config, input: {
239
244
  owner: string;
240
245
  repo: string;
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { apiBase, loadConfig, resolveApiKey } from "./config.js";
2
2
  import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, } from "./automation-catalog.js";
3
- import { CommandError, REVIEW_EXIT, rateLimitedMessage, } from "./errors.js";
3
+ import { CommandError, REVIEW_EXIT, rateLimitedMessage, plainServerText, } from "./errors.js";
4
4
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
5
5
  export const DEFAULT_API_TIMEOUT_MS = 30_000;
6
6
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
@@ -416,12 +416,53 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
416
416
  : code === "supabase_unconfigured"
417
417
  ? "Stacks are not configured on this server."
418
418
  : undefined;
419
+ const surge = surgeRefusal(body);
420
+ if (surge)
421
+ throw surge;
419
422
  throw new CommandError(message?.trim()
420
423
  ? message
421
424
  : fallbackMessage ?? `Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`, 1, code);
422
425
  }
423
426
  return body;
424
427
  }
428
+ const INFRASTRUCTURE_REFUSAL_CODES = [
429
+ "cyclone_not_connected",
430
+ "cyclone_not_installed",
431
+ "cyclone_permission_missing",
432
+ ];
433
+ function refusalReason(body) {
434
+ const reason = (body && typeof body === "object" ? body.reason : undefined);
435
+ return typeof reason === "string" && plainServerText(reason).trim() ? plainServerText(reason).trim() : undefined;
436
+ }
437
+ export const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
438
+ export function surgeRefusal(body) {
439
+ if (!body || typeof body !== "object")
440
+ return null;
441
+ const refusal = body;
442
+ const reason = refusalReason(body);
443
+ if (!reason?.startsWith("surge_"))
444
+ return null;
445
+ const error = INFRASTRUCTURE_REFUSAL_CODES.find((code) => code === refusal.error);
446
+ if (!error)
447
+ return null;
448
+ const message = serverMessage(refusal.message) ?? SURGE_REFUSAL_FALLBACK;
449
+ const installUrl = githubAppInstallUrl(refusal.installUrl) ?? "";
450
+ return new CommandError(installUrl && !message.includes(installUrl) ? `${message} (${installUrl})` : message, 1, error, { reason });
451
+ }
452
+ export const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
453
+ function serverMessage(message) {
454
+ if (typeof message !== "string")
455
+ return null;
456
+ const plain = plainServerText(message).trim();
457
+ return plain ? plain : null;
458
+ }
459
+ const GITHUB_APP_INSTALL_URL = /https:\/\/github\.com\/apps\/[^\s)]+\/installations\/new/;
460
+ function githubAppInstallUrl(value) {
461
+ if (typeof value !== "string")
462
+ return null;
463
+ const match = GITHUB_APP_INSTALL_URL.exec(plainServerText(value).trim());
464
+ return match && match.index === 0 ? match[0] : null;
465
+ }
425
466
  /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
426
467
  export async function ensureUpperPark(stackId, cfg) {
427
468
  const body = await stackMutation(stackId, "/ensure-upper-park", "POST", {}, "Failed to ensure upper-park freeze", cfg);
@@ -437,22 +478,25 @@ export async function ensureUpperPark(stackId, cfg) {
437
478
  created: body.created === true,
438
479
  };
439
480
  }
440
- export const CYCLONE_INSTALL_URL = "https://github.com/apps/mergestorm-cyclone/installations/new";
441
- export function cycloneNotInstalledMessage(owner, repo, installUrl = CYCLONE_INSTALL_URL) {
442
- return (`Opening PRs needs either the GitHub CLI (gh auth login) or Cyclone installed on ` +
481
+ export const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
482
+ export function surgeNotInstalledMessage(owner, repo, installUrl = SURGE_INSTALL_URL) {
483
+ return (`Opening PRs needs either the GitHub CLI (gh auth login) or Mergestorm Surge installed on ` +
443
484
  `${owner}/${repo} (${installUrl}).`);
444
485
  }
445
486
  function installUrlFrom(message) {
446
487
  if (typeof message !== "string")
447
- return CYCLONE_INSTALL_URL;
448
- return message.match(/https:\/\/github\.com\/apps\/[^\s)]+\/installations\/new/)?.[0] ?? CYCLONE_INSTALL_URL;
488
+ return SURGE_INSTALL_URL;
489
+ return message.match(GITHUB_APP_INSTALL_URL)?.[0] ?? SURGE_INSTALL_URL;
449
490
  }
450
491
  function stackPullFailure(status, body, owner, repo, failLabel) {
451
492
  const rejection = (body && typeof body === "object" ? body : {});
493
+ const surge = surgeRefusal(body);
494
+ if (surge)
495
+ return surge;
452
496
  const text = `${String(rejection.error ?? "")} ${String(rejection.message ?? "")}`;
453
497
  if (rejection.error === "cyclone_not_installed" ||
454
498
  /not installed|installation[^\n]*not found/i.test(text)) {
455
- return new CommandError(cycloneNotInstalledMessage(owner, repo, installUrlFrom(rejection.message)), 1, "cyclone_not_installed");
499
+ return new CommandError(surgeNotInstalledMessage(owner, repo, installUrlFrom(rejection.message)), 1, "cyclone_not_installed");
456
500
  }
457
501
  if (status === 404) {
458
502
  return new CommandError("Stacks API cannot open PRs on this server yet. Install the GitHub CLI (gh auth login) and retry.");
@@ -500,6 +544,9 @@ async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg)
500
544
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
501
545
  }
502
546
  if (status !== 200) {
547
+ const surge = surgeRefusal(body);
548
+ if (surge)
549
+ throw surge;
503
550
  const rejection = body;
504
551
  if (method === "PATCH" && rejection?.error === "cyclone_not_connected") {
505
552
  throw new CommandError(typeof rejection.message === "string" ? rejection.message : rejection.error, 1, "cyclone_not_connected");
@@ -554,7 +601,7 @@ export async function listMergeQueueEntries(cfg, opts) {
554
601
  }
555
602
  return entries;
556
603
  }
557
- async function queueMutation(route, failLabel, cfg) {
604
+ async function queueMutation(route, failLabel, cfg, opts = {}) {
558
605
  const resolved = cfg ?? (await loadConfig());
559
606
  const { status, body } = await apiFetch(resolved, route, {
560
607
  method: "POST",
@@ -572,18 +619,27 @@ async function queueMutation(route, failLabel, cfg) {
572
619
  }
573
620
  if (status === 409) {
574
621
  const refusal = body;
575
- if (refusal?.error === "conflict_unchanged" && typeof refusal.message === "string") {
622
+ if ((refusal?.error === "conflict_unchanged" || refusal?.error === "ci_failed") &&
623
+ typeof refusal.message === "string") {
576
624
  throw new CommandError(refusal.message);
577
625
  }
578
626
  }
627
+ if (opts.githubUnreadableRefusal &&
628
+ status === 503 &&
629
+ body?.error === "github_unreadable") {
630
+ throw new CommandError(serverMessage(body.message) ?? GITHUB_UNREADABLE_MESSAGE, 1, "github_unreadable");
631
+ }
579
632
  if (status < 200 || status >= 300) {
633
+ const surge = surgeRefusal(body);
634
+ if (surge)
635
+ throw surge;
580
636
  throw new CommandError(`${failLabel} (HTTP ${status}): ${JSON.stringify(body)}`);
581
637
  }
582
638
  return body;
583
639
  }
584
640
  /** Enqueue a stack for verified landing. */
585
641
  export async function enqueueStack(stackId, cfg) {
586
- return queueMutation(`/api/v1/stacks/${encodeURIComponent(stackId)}/enqueue`, "Failed to add stack to merge queue", cfg);
642
+ return queueMutation(`/api/v1/stacks/${encodeURIComponent(stackId)}/enqueue`, "Failed to add stack to merge queue", cfg, { githubUnreadableRefusal: true });
587
643
  }
588
644
  /** Cancel a live merge-queue entry by entry id. */
589
645
  export async function cancelMergeQueueEntry(entryId, cfg) {
@@ -28,6 +28,11 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
28
28
  export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
29
  /** Human labels for the Config tab and the static `settings` printout. */
30
30
  export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
31
+ export declare const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
32
+ export declare const STACKS_READY_WORDS: {
33
+ readonly on: "ready";
34
+ readonly off: "not ready (install Mergestorm Surge)";
35
+ };
31
36
  /** Config rows in a stable order: writable toggles, then connected flags. */
32
37
  export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
33
38
  /**
@@ -121,8 +121,14 @@ export function formatJobDetailLines(row) {
121
121
  }
122
122
  /** Human labels for the Config tab and the static `settings` printout. */
123
123
  export const SETTINGS_LABELS = BEARER_SETTINGS_LABELS;
124
+ export const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
125
+ export const STACKS_READY_WORDS = {
126
+ on: "ready",
127
+ off: "not ready (install Mergestorm Surge)",
128
+ };
124
129
  /** Config rows in a stable order: writable toggles, then connected flags. */
125
130
  export function buildConfigRows(settings) {
131
+ const stacksReadyKnown = typeof settings.stacks_ready === "boolean";
126
132
  return [
127
133
  ...BEARER_BOOLEAN_SETTINGS_KEYS.map((key) => ({
128
134
  key,
@@ -130,9 +136,20 @@ export function buildConfigRows(settings) {
130
136
  value: settings[key] === true,
131
137
  writable: true,
132
138
  })),
139
+ ...(stacksReadyKnown
140
+ ? [
141
+ {
142
+ key: "stacks_ready",
143
+ label: STACKS_READY_LABEL,
144
+ value: settings.stacks_ready === true,
145
+ writable: false,
146
+ readOnlyWords: STACKS_READY_WORDS,
147
+ },
148
+ ]
149
+ : []),
133
150
  {
134
151
  key: "cyclone_connected",
135
- label: "Cyclone",
152
+ label: stacksReadyKnown ? "Cyclone (auto patch)" : "Cyclone",
136
153
  value: settings.cyclone_connected === true,
137
154
  writable: false,
138
155
  },
@@ -1,4 +1,4 @@
1
- import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus } from "./review-client.js";
1
+ import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, type PollReviewOptions } from "./review-client.js";
2
2
  export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
3
3
  /** Same public specialist allowlist as the API contract and migration 117. */
4
4
  export declare const VORTEX_ROUTER_MODES: readonly ["off", "standard", "max", "manual"];
@@ -43,6 +43,7 @@ export type ReviewOptions = {
43
43
  interactive?: boolean;
44
44
  /** Test seam; production uses the two-second poll interval. */
45
45
  pollIntervalMs?: number;
46
+ pollClock?: Pick<PollReviewOptions, "sleep" | "now" | "random">;
46
47
  };
47
48
  export declare function reviewStatusHint(jobId: string): string;
48
49
  /** Append recovery hint once a job id exists (idempotent). */
@@ -357,6 +357,7 @@ export async function cmdReview(args, opts = {}) {
357
357
  row = await pollReview(cfg, jobId, {
358
358
  timeoutMs: parsed.timeoutMs,
359
359
  intervalMs: opts.pollIntervalMs,
360
+ ...opts.pollClock,
360
361
  signal: opts.signal,
361
362
  propagateTimeout: interactive,
362
363
  onTick(kind) {
@@ -3,6 +3,7 @@ import { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS } from "../automation-catalog.js
3
3
  import { loadConfig } from "../config.js";
4
4
  import { CommandError } from "../errors.js";
5
5
  import { present } from "../ui/present.js";
6
+ import { readOnlyRowWords } from "../ui/tabs.js";
6
7
  import { buildConfigRows, canBrowse, openTabsBrowser } from "./browse.js";
7
8
  /**
8
9
  * CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
@@ -91,9 +92,7 @@ export function formatSettingsLines(settings) {
91
92
  ? row.value
92
93
  ? "on"
93
94
  : "off"
94
- : row.value
95
- ? "connected"
96
- : "not connected";
95
+ : readOnlyRowWords(row);
97
96
  return ` ${row.label.padEnd(labelWidth + 2)}${value}`;
98
97
  });
99
98
  }
@@ -37,7 +37,7 @@ export function stackWatchTextLines(watch) {
37
37
  const next = watch.next;
38
38
  return [
39
39
  ...(next ? [
40
- `Next: ${next.background} as a background command (notify on ${STACK_WATCH_NOTIFY_PATTERN}, then end your turn)`,
40
+ `Only with a confirmed host notification that resumes this task: run ${next.background} as a background command (notify on ${STACK_WATCH_NOTIFY_PATTERN}), then end your turn`,
41
41
  `Or poll: ${next.command} (MCP: stack_wait ${JSON.stringify(next.args)})`,
42
42
  ] : []),
43
43
  STACK_WATCH_NOT_DONE_SENTENCE,
@@ -58,7 +58,7 @@ const STACK_USAGE = `usage:
58
58
  mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
59
59
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
60
60
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or a repeated failed read; prints
61
- MS-WATCH LANDED and exits 0 once the watch is done. Run it as a background command.
61
+ MS-WATCH LANDED and exits 0 only for a confirmed landing. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
62
62
  --json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
63
63
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
64
64
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
@@ -996,7 +996,9 @@ export async function cmdStackWatch(args, deps = {}) {
996
996
  ? `Stopped watching stack ${stackId} after --max; it is not landed.`
997
997
  : result.outcome === "aborted"
998
998
  ? `Stopped watching stack ${stackId}; it is not landed.`
999
- : `Stack ${stackId} needs attention; it is not landed.`;
999
+ : result.envelope?.watch.reason === "not_found"
1000
+ ? `Stack ${stackId} was not found; landing is unconfirmed. Verify each PR before reporting it landed.`
1001
+ : `Stack ${stackId} needs attention; it is not landed.`;
1000
1002
  throw new CommandError(reason, result.exitCode, result.outcome === "timeout" ? "review_timeout" : "stack_attention");
1001
1003
  }
1002
1004
  export async function cmdStackStatus(args, deps = {}) {
@@ -1040,8 +1042,7 @@ export async function cmdStackStatus(args, deps = {}) {
1040
1042
  lines.push(` ${String(layer.position).padStart(2)} ${pr}${head} ${layer.state.padEnd(14)} ${layer.branch}` +
1041
1043
  ` CI: ${layer.ciStatus ?? "unknown"} review: ${layer.reviewStatus ?? "unknown"}` +
1042
1044
  (layer.vortexStatus ? ` Vortex: ${layer.vortexStatus}` : "") +
1043
- (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
1044
- (layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
1045
+ (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : ""));
1045
1046
  }
1046
1047
  if (queueWait && !attention)
1047
1048
  lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
@@ -1147,7 +1148,7 @@ async function cmdStackLand(args) {
1147
1148
  await present("Stack", [
1148
1149
  ansi.brightGreen(` Promoted` +
1149
1150
  (pr != null ? ` #${pr}` : "") +
1150
- ` into U${uNumber}` +
1151
+ ` into IU ${uNumber}` +
1151
1152
  (branch ? ` (${branch})` : "") +
1152
1153
  ` on ${id}`),
1153
1154
  ]);
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" | "stack_attention";
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" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "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;
@@ -15,14 +15,17 @@ export declare const REVIEW_EXIT: {
15
15
  };
16
16
  export type CommandErrorExtras = {
17
17
  retryAfterSeconds?: number;
18
+ reason?: string;
18
19
  };
19
20
  /** Thrown by command handlers; one-shot mode maps `exitCode` onto process.exitCode. */
20
21
  export declare class CommandError extends Error {
21
22
  readonly exitCode: number;
22
23
  readonly code?: CommandErrorCode;
23
24
  readonly retryAfterSeconds?: number;
25
+ readonly reason?: string;
24
26
  constructor(message: string, exitCode?: number, code?: CommandErrorCode, extras?: CommandErrorExtras);
25
27
  }
28
+ export declare function plainServerText(text: string): string;
26
29
  export declare const RATE_LIMITED_HINT = "Rate limited. Wait for a running review or mergestorm jobs.";
27
30
  export declare function rateLimitedMessage(retryAfterSeconds?: number): string;
28
31
  export declare function isCommandErrorCode(err: unknown, code: CommandErrorCode): err is CommandError;
package/dist/errors.js CHANGED
@@ -13,6 +13,7 @@ export class CommandError extends Error {
13
13
  exitCode;
14
14
  code;
15
15
  retryAfterSeconds;
16
+ reason;
16
17
  constructor(message, exitCode = 1, code, extras) {
17
18
  super(message);
18
19
  this.name = "CommandError";
@@ -21,8 +22,16 @@ export class CommandError extends Error {
21
22
  if (extras?.retryAfterSeconds !== undefined) {
22
23
  this.retryAfterSeconds = extras.retryAfterSeconds;
23
24
  }
25
+ if (extras?.reason !== undefined) {
26
+ this.reason = extras.reason;
27
+ }
24
28
  }
25
29
  }
30
+ const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]/g;
31
+ const CONTROL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
32
+ export function plainServerText(text) {
33
+ return text.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARACTER, "");
34
+ }
26
35
  export const RATE_LIMITED_HINT = "Rate limited. Wait for a running review or mergestorm jobs.";
27
36
  export function rateLimitedMessage(retryAfterSeconds) {
28
37
  if (retryAfterSeconds == null || !Number.isFinite(retryAfterSeconds)) {
@@ -6,6 +6,10 @@ export type StackBlocker = {
6
6
  blocker: string;
7
7
  bounceKind: string | null;
8
8
  };
9
+ export type StackBlockerReason = "restack_conflict" | "restack_failed" | "draft_pr" | "merge_conflict" | "ci_failed" | "vortex_failed" | "vortex_out_of_quota" | "vortex_skipped" | "vortex_findings_need_person" | "seam_failed" | "seam_findings" | "seam_not_running" | "seam_review_stuck" | "seam_rereview_not_running" | "seam_verdict_stale" | "unit_abandoned" | "land_gate" | "bounce" | "auto_land_off" | "auto_land_ci_pending" | "auto_land_members_remaining" | "auto_land_vortex_wait" | "auto_land_promote_failed" | "auto_land_ci_unreadable" | "auto_land_waited" | "queue_stalled" | "land_pr_missing";
10
+ export type ClassifiedStackBlocker = StackBlocker & {
11
+ reason: StackBlockerReason;
12
+ };
9
13
  export type StackAgentBusy = {
10
14
  prNumber: number;
11
15
  headSha: string | null;
@@ -47,20 +51,6 @@ export type StackRepairHint = {
47
51
  branch: string;
48
52
  failingCheck: string | null;
49
53
  steps: string;
50
- } | {
51
- kind: "tempest_findings";
52
- prNumber: number;
53
- headSha: string | null;
54
- branch: string;
55
- findings: string;
56
- steps: string;
57
- } | {
58
- kind: "tempest_rerun";
59
- prNumber: number;
60
- headSha: string | null;
61
- branch: string;
62
- stoppedByPerson: boolean;
63
- steps: string;
64
54
  } | {
65
55
  kind: "vortex_findings";
66
56
  prNumber: number;
@@ -121,7 +111,7 @@ export type StackRepairHint = {
121
111
  waitReason: string | null;
122
112
  steps: string;
123
113
  } | {
124
- kind: "members_remaining" | "vortex_failed" | "tempest_trigger_failed";
114
+ kind: "members_remaining" | "vortex_failed";
125
115
  prNumber: number;
126
116
  headSha: string | null;
127
117
  branch: string;
@@ -211,8 +201,8 @@ export declare function restackFailedRepairSteps(branch: string, liveParent: str
211
201
  kind: string;
212
202
  detail?: string | null;
213
203
  }): string;
214
- export declare function agentsCannotClear(blocker: string): boolean;
215
- export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
204
+ export declare function agentsCannotClear(reason: StackBlockerReason): boolean;
205
+ export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
216
206
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
217
207
  /** Pair-gate attention plus live hard blocks on every other open layer. */
218
208
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {