mergestorm 0.3.61 → 0.3.62

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,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.61 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.62 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
52
52
  │ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
53
- │ ▀ New in v0.3.61: Watch names a job Cyclone's push skips │
53
+ │ ▀ New in v0.3.62: Watch wakes for a blocker on any layer │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -111,7 +111,7 @@ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --
111
111
 
112
112
  - 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.
113
113
  - It prints nothing while the stack is `waiting` or `in_progress`, including while Cyclone or Vortex holds the blocked PR (`busy[]`, `actAfter: "agents_idle"`).
114
- - On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3.
114
+ - On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3. Attention names the lowest layer with a blocker an agent must fix, on any layer of the stack, not only the current candidate.
115
115
  - 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.
116
116
  - 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.
117
117
  - `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
@@ -148,7 +148,7 @@ If the parent branch is already a layer of a **registered** (submitted) stack, `
148
148
 
149
149
  `mg stack submit --server-side` and `mg stack publish` are for a machine that can reach Mergestorm but cannot authenticate to GitHub, such as a cloud coding sandbox. They never run `gh`, `git push` or `git fetch`. `mg` reads your commits from the local repository and sends them to Mergestorm with your API key; Mergestorm writes those exact commits and the branch on GitHub as the Surge App, opens a ready pull request, and registers the stack. Surge must be installed on the repository and your linked GitHub account needs write access to it.
150
150
 
151
- These are your own changes, not Cyclone's: the stack is registered with **Auto patch pinned off**, Auto land on for a new stack (`--auto-land off` to keep it off), and Auto review left to your account setting. `--auto-patch on` is refused in this mode. Fix review findings yourself and send the new commits with `mg stack publish`.
151
+ The stack is registered with Auto land on for a new stack (`--auto-land off` to keep it off). Auto patch and Auto review follow the stack, repository and account settings like any other stack; `--auto-patch` and `--auto-review` pin them on the stack after it is registered. Send later commits with `mg stack publish`.
152
152
 
153
153
  ```bash
154
154
  mg issue create --title "The thing is broken" --body-file issue.md --label bug --json # no gh
@@ -167,7 +167,7 @@ mg stack publish --status # absent | pushed | opened | registered
167
167
  - **Read-only home directory.** `mg` keeps the saved login and the local stack state of `stack create` / `stack submit` under `~/.mergestorm`. Where the home directory cannot be written, set `MERGESTORM_HOME` to a writable directory (outside the repository) and every command uses that instead. `MERGESTORM_API_KEY` supplies the key without `mg login`.
168
168
  - **Limits.** 20 commits, 500 changed paths, 2 MiB a file and 8 MiB in total per publication. Submodules and merge commits are refused with the path or the reason. `stack submit --server-side` publishes the first two layers of a stack; a third layer needs a park freeze, which this mode does not create, so add it once the first two are registered.
169
169
 
170
- 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.
170
+ 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.
171
171
 
172
172
  ### `login`
173
173
 
package/dist/api.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { type Config } from "./config.js";
2
- import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, type BearerSettingsKey as SettingsWritableKey } from "./automation-catalog.js";
2
+ import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS } from "./automation-catalog.js";
3
3
  import { CommandError } from "./errors.js";
4
4
  import type { MergeQueueEntryDto, StackDto } 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
+ export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackDto, StackLayerDto, } from "./stack-dto.js";
6
6
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
7
7
  export declare const DEFAULT_API_TIMEOUT_MS = 30000;
8
8
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
@@ -167,7 +167,6 @@ export declare function applyPrReviewQuery(query: URLSearchParams, opts: PrRevie
167
167
  * The connected flags are read-only there and deliberately absent here.
168
168
  */
169
169
  export { SETTINGS_WRITABLE_KEYS };
170
- export type { SettingsWritableKey };
171
170
  export type SettingsPatch = Partial<import("./automation-catalog.js").BearerSettingsValues>;
172
171
  /** GET and PATCH `/api/v1/settings` both answer with this shape. */
173
172
  export type SettingsResponse = import("./automation-catalog.js").BearerSettingsValues & {
@@ -389,8 +388,6 @@ export declare function openStackIssue(input: {
389
388
  url: string;
390
389
  created: boolean;
391
390
  }>;
392
- /** Arm or disarm Auto land on one owned stack. */
393
- export declare function setStackAutoLand(stackId: string, enabled: boolean, cfg?: Config): Promise<unknown>;
394
391
  /**
395
392
  * Patch any subset of one owned stack's automation policy
396
393
  * (Bearer PATCH /api/v1/stacks/:id). `null` clears an override.
@@ -399,9 +396,7 @@ export declare function setStackPolicy(stackId: string, policy: StackPolicyPatch
399
396
  /** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
400
397
  export declare function restackStack(stackId: string, cfg?: Config): Promise<unknown>;
401
398
  /** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
402
- export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
403
- prNumber?: number;
404
- }): Promise<unknown>;
399
+ export declare function landNextStack(stackId: string, cfg?: Config): Promise<unknown>;
405
400
  /** List the authenticated user's live merge-queue entries. */
406
401
  export declare function listMergeQueueEntries(cfg?: Config, opts?: {
407
402
  stackId?: string;
package/dist/api.js CHANGED
@@ -269,8 +269,6 @@ export async function getMe(cfg, opts) {
269
269
  const { status, body } = await apiFetch(resolved, "/api/v1/me", {
270
270
  timeoutMs: opts?.timeoutMs,
271
271
  });
272
- if (status === 404)
273
- return null;
274
272
  if (status !== 200)
275
273
  return null;
276
274
  return body;
@@ -432,18 +430,7 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
432
430
  const message = body && typeof body === "object" && "message" in body && typeof body.message === "string"
433
431
  ? body.message
434
432
  : undefined;
435
- const code = error === "auth_invalid" ||
436
- error === "api_timeout" ||
437
- error === "not_a_repo" ||
438
- error === "not_found" ||
439
- error === "missing_api_key" ||
440
- error === "registered_parent" ||
441
- error === "usage" ||
442
- error === "review_quota" ||
443
- error === "review_failed" ||
444
- error === "review_timeout" ||
445
- error === "rate_limited" ||
446
- error === "cyclone_not_installed" ||
433
+ const code = error === "cyclone_not_installed" ||
447
434
  error === "pr_merged" ||
448
435
  error === "pr_closed" ||
449
436
  error === "not_stacked" ||
@@ -689,10 +676,6 @@ async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg)
689
676
  }
690
677
  return body;
691
678
  }
692
- /** Arm or disarm Auto land on one owned stack. */
693
- export async function setStackAutoLand(stackId, enabled, cfg) {
694
- return setStackPolicy(stackId, { autoEnqueueWhenReady: enabled }, cfg);
695
- }
696
679
  /**
697
680
  * Patch any subset of one owned stack's automation policy
698
681
  * (Bearer PATCH /api/v1/stacks/:id). `null` clears an override.
@@ -709,11 +692,8 @@ export async function restackStack(stackId, cfg) {
709
692
  return stackMutation(stackId, "/restack", "POST", {}, "Failed to restack", cfg);
710
693
  }
711
694
  /** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
712
- export async function landNextStack(stackId, cfg, opts) {
713
- const json = opts?.prNumber != null && Number.isInteger(opts.prNumber) && opts.prNumber > 0
714
- ? { prNumber: opts.prNumber }
715
- : {};
716
- return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
695
+ export async function landNextStack(stackId, cfg) {
696
+ return stackMutation(stackId, "/land-next", "POST", {}, "Failed to land next", cfg);
717
697
  }
718
698
  /** List the authenticated user's live merge-queue entries. */
719
699
  export async function listMergeQueueEntries(cfg, opts) {
@@ -1,6 +1,6 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, type Config, } from "../config.js";
2
2
  export { BEARER_SETTINGS, BEARER_BOOLEAN_SETTINGS_KEYS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, type BearerSettingsKey, } from "../automation-catalog.js";
3
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, submitStackPublication, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrDismissedFinding, type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type RepoSettings, type RepoSettingsKey, type RepoSettingsPatch, type SettingsResponse, type SettingsWritableKey, type StackDto, type StackPolicyPatch, type StackPublication, type StackPublicationRequest, type StackPublicationStatus, type ThreadDetail, type ThreadListItem, } from "../api.js";
3
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrDismissedFinding, type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type RepoSettings, type RepoSettingsKey, type RepoSettingsPatch, type SettingsResponse, type StackDto, type StackPolicyPatch, type StackPublication, type StackPublicationRequest, type StackPublicationStatus, type ThreadDetail, type ThreadListItem, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, type CommandErrorCode, type CommandErrorExtras, } from "../errors.js";
5
5
  export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, noPrReviewYetMessage, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
@@ -1,6 +1,6 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, } from "../config.js";
2
2
  export { BEARER_SETTINGS, BEARER_BOOLEAN_SETTINGS_KEYS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, } from "../automation-catalog.js";
3
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, submitStackPublication, } from "../api.js";
3
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
5
5
  export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, noPrReviewYetMessage, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
@@ -6,4 +6,7 @@ import { type MeResponse } from "../api.js";
6
6
  */
7
7
  export declare function validateApiKey(cfg: Config): Promise<MeResponse>;
8
8
  export declare function pendingLoginPath(): string;
9
- export declare function cmdLogin(args: string[]): Promise<void>;
9
+ export interface LoginDeps {
10
+ openBrowser: (url: string) => void;
11
+ }
12
+ export declare function cmdLogin(args: string[], deps?: LoginDeps): Promise<void>;
@@ -200,7 +200,7 @@ async function loginFinish() {
200
200
  console.log(`Waiting for approval of code ${pending.userCode} at ${pending.openUrl}`);
201
201
  await pollDeviceLogin(pending, "mergestorm login --finish", "Run `mergestorm login --start`, then `mergestorm login --finish`.");
202
202
  }
203
- export async function cmdLogin(args) {
203
+ export async function cmdLogin(args, deps = { openBrowser }) {
204
204
  if (args.includes("--key")) {
205
205
  await loginWithKey();
206
206
  return;
@@ -219,8 +219,10 @@ export async function cmdLogin(args) {
219
219
  console.log("To sign in, open this URL and confirm the code:\n");
220
220
  console.log(` ${ansi.brightGreen(pending.openUrl)}`);
221
221
  console.log(`\n Code: ${ansi.bold(pending.userCode)}\n`);
222
- openBrowser(pending.openUrl);
223
- if (!process.stdout.isTTY) {
222
+ if (process.stdout.isTTY) {
223
+ deps.openBrowser(pending.openUrl);
224
+ }
225
+ else {
224
226
  console.log("No terminal here. If this command may be cut off before the approval, use `mergestorm login --start`, then `mergestorm login --finish`.");
225
227
  }
226
228
  console.log("Waiting for approval…");
@@ -264,7 +264,7 @@ export const COMMAND_REGISTRY = [
264
264
  "mergestorm stack list [--json] List registered stacks",
265
265
  "mergestorm stack status <stack-id> [--json] Enriched stack status",
266
266
  "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",
267
- "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",
267
+ "mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json] Background watcher: silent while waiting; MS-WATCH ATTENTION exits 3, MS-WATCH LANDED exits 0",
268
268
  "mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--auto-resolve-conflicts on|off|default] [--auto-fix-ci on|off|default] [--json] Per-stack policy",
269
269
  "mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] Import an open PR chain",
270
270
  "mergestorm stack restack <stack-id> Restack stack descendants",
@@ -10,8 +10,6 @@ export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
10
10
  export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
11
11
  /** Same thread slug contract as POST /api/v1/reviews. */
12
12
  export declare const REVIEW_THREAD_SLUG_RE: RegExp;
13
- /** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
14
- export declare const MAX_REVIEW_DIFF_BYTES = 1000000;
15
13
  export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
16
14
  export declare const MAX_CONTEXT_FILES = 10;
17
15
  export declare const MAX_CONTEXT_FILE_CHARS = 16000;
@@ -59,6 +57,7 @@ export type LoadReviewContextOptions = {
59
57
  };
60
58
  /** Load --context / --context-file and enforce the API caps client-side. */
61
59
  export declare function loadReviewContext(opts: LoadReviewContextOptions): Promise<ReviewContextPayload>;
60
+ export declare function reviewIdempotencyKey(input: ReviewInput, opts?: SubmitReviewOptions, afterJobId?: string | null): string;
62
61
  export declare function submitReview(cfg: Config, input: ReviewInput, opts?: SubmitReviewOptions): Promise<{
63
62
  status: number;
64
63
  row: ReviewJobRow;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, realpath } from "node:fs/promises";
2
3
  import { join, relative, resolve, sep } from "node:path";
3
4
  import { apiFetch, applyPrReviewQuery, isPositiveInteger, } from "../api.js";
@@ -12,8 +13,6 @@ export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
12
13
  export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
13
14
  /** Same thread slug contract as POST /api/v1/reviews. */
14
15
  export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
15
- /** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
16
- export const MAX_REVIEW_DIFF_BYTES = 1_000_000;
17
16
  export const MAX_CONTEXT_TEXT_CHARS = 60_000;
18
17
  export const MAX_CONTEXT_FILES = 10;
19
18
  export const MAX_CONTEXT_FILE_CHARS = 16_000;
@@ -128,7 +127,29 @@ export async function loadReviewContext(opts) {
128
127
  ...(contextFiles.length > 0 ? { contextFiles } : {}),
129
128
  };
130
129
  }
130
+ const REPLAYED_JOB_RESUBMIT_STATUSES = ["failed", "quota_exceeded"];
131
+ const MAX_REPLAYED_JOB_RESUBMITS = 5;
132
+ export function reviewIdempotencyKey(input, opts = {}, afterJobId = null) {
133
+ const request = [
134
+ input.thread, input.branch, input.repo ?? null, input.baseLabel, input.headLabel, input.diff, input.files,
135
+ opts.routerMode ?? null, [...new Set(opts.specialists ?? [])].sort(), opts.context ?? null, opts.contextFiles ?? [], opts.webhookUrl ?? null,
136
+ afterJobId,
137
+ ];
138
+ return `mg-auto-${createHash("sha256").update(JSON.stringify(request)).digest("hex")}`;
139
+ }
131
140
  export async function submitReview(cfg, input, opts = {}) {
141
+ const explicitKey = opts.idempotencyKey?.trim() || undefined;
142
+ let idempotencyKey = explicitKey ?? reviewIdempotencyKey(input, opts);
143
+ for (let resubmits = 0;; resubmits++) {
144
+ const submitted = await postReview(cfg, input, opts, idempotencyKey);
145
+ const replayedJobId = submitted.row.job_id ?? submitted.row.id ?? null;
146
+ if (explicitKey || submitted.status !== 200 || !replayedJobId || resubmits >= MAX_REPLAYED_JOB_RESUBMITS ||
147
+ !REPLAYED_JOB_RESUBMIT_STATUSES.includes(submitted.row.status ?? ""))
148
+ return submitted;
149
+ idempotencyKey = reviewIdempotencyKey(input, opts, replayedJobId);
150
+ }
151
+ }
152
+ async function postReview(cfg, input, opts, idempotencyKey) {
132
153
  const { status, body, retryAfterSeconds } = await apiFetch(cfg, "/api/v1/reviews", {
133
154
  method: "POST",
134
155
  json: {
@@ -143,7 +164,7 @@ export async function submitReview(cfg, input, opts = {}) {
143
164
  ...(opts.specialists?.length ? { specialists: opts.specialists } : {}),
144
165
  ...(opts.context ? { context: opts.context } : {}),
145
166
  ...(opts.contextFiles?.length ? { context_files: opts.contextFiles } : {}),
146
- ...(opts.idempotencyKey ? { idempotency_key: opts.idempotencyKey } : {}),
167
+ idempotency_key: idempotencyKey,
147
168
  ...(opts.webhookUrl ? { webhook_url: opts.webhookUrl } : {}),
148
169
  },
149
170
  signal: opts.signal,
@@ -59,11 +59,13 @@ const STACK_USAGE = `usage:
59
59
  mergestorm stack wait <stack-id> [--json] [--timeout <s>]
60
60
  --json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
61
61
  (exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
62
- mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
62
+ mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]
63
63
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
64
64
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
65
65
  error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
66
66
  and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
67
+ An ATTENTION line for a merge queue bounce ends with restart="mg stack watch <id> --bounce-id <id> --after-finished-at <iso>":
68
+ restart with that command (plus --head <sha> after a push) so a bounce you already handled is not reported again.
67
69
  --json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
68
70
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--auto-resolve-conflicts on|off|default] [--auto-fix-ci on|off|default] [--json]
69
71
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
@@ -85,7 +87,8 @@ const STACK_USAGE = `usage:
85
87
  stack submit --server-side and stack publish need no GitHub credentials: mg
86
88
  reads your commits from the local repository and sends them to Mergestorm,
87
89
  which writes the commits and the branch on GitHub as the Surge App, opens the
88
- pull request, and registers the stack with Auto patch pinned off. They never
90
+ pull request, and registers the stack. Auto patch follows the stack, the
91
+ repository and your account, like any other stack. They never
89
92
  run gh, git push or git fetch. stack publish does this for one branch and is
90
93
  how you send later commits to a branch that is already published. Mergestorm
91
94
  refuses to move a branch whose head on GitHub matches no local commit unless
@@ -782,9 +785,6 @@ export async function cmdStackSubmit(args, deps = {}) {
782
785
  }
783
786
  }
784
787
  if (serverSide) {
785
- if (autoPatch === true) {
786
- throw new CommandError("--server-side publishes your own commits and keeps Auto patch off for the stack. Drop --auto-patch on, or submit without --server-side.", REVIEW_EXIT.usage, "usage");
787
- }
788
788
  const parked = plans.find((plan) => plan.githubBase == null);
789
789
  if (parked) {
790
790
  throw new CommandError(`Layer ${parked.branch} opens onto a park freeze, which --server-side does not mint. ` +
@@ -796,6 +796,7 @@ export async function cmdStackSubmit(args, deps = {}) {
796
796
  const remoteHeads = new Map();
797
797
  const published = [];
798
798
  let publishedStackId = null;
799
+ let publishedAutoPatch = null;
799
800
  for (const plan of plans) {
800
801
  if (!branchExistsFn(plan.branch, cwd)) {
801
802
  throw new CommandError(`Stack layer branch missing locally: ${plan.branch}`);
@@ -825,6 +826,7 @@ export async function cmdStackSubmit(args, deps = {}) {
825
826
  });
826
827
  remoteHeads.set(plan.branch, result.head.sha);
827
828
  publishedStackId = result.stack.id;
829
+ publishedAutoPatch = result.stack.autoPatchOverride;
828
830
  published.push({
829
831
  branch: plan.branch,
830
832
  base: result.baseBranch,
@@ -834,19 +836,19 @@ export async function cmdStackSubmit(args, deps = {}) {
834
836
  tree: result.head.tree,
835
837
  });
836
838
  }
837
- const { autoEnqueueWhenReady: _autoLand, autoPatchOverride: _autoPatch, ...otherOverrides } = policy ?? {};
839
+ const { autoEnqueueWhenReady: _autoLand, ...otherOverrides } = policy ?? {};
838
840
  if (publishedStackId && Object.keys(otherOverrides).length > 0) {
839
841
  await (deps.setStackPolicy ?? setStackPolicy)(publishedStackId, otherOverrides, config);
840
842
  }
841
843
  await saveStackMetaFn(removeActiveStack(meta), cwd);
842
844
  const watch = publishedStackId ? openStackWatch(publishedStackId) : null;
843
845
  if (asJson) {
844
- console.log(JSON.stringify({ owner, repo, layers: published, stackId: publishedStackId, trunk: meta.trunk, chain: null, serverSide: true, autoPatchOverride: false, watch }, null, 2));
846
+ console.log(JSON.stringify({ owner, repo, layers: published, stackId: publishedStackId, trunk: meta.trunk, chain: null, serverSide: true, autoPatchOverride: otherOverrides.autoPatchOverride ?? publishedAutoPatch, watch }, null, 2));
845
847
  return;
846
848
  }
847
849
  const lines = published.map((row) => ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${row.created ? "opened" : "exists"}, head ${row.head.slice(0, 12)})`));
848
850
  if (publishedStackId)
849
- lines.push(ansi.brightGreen(` Registered stack ${publishedStackId} with Auto patch off`));
851
+ lines.push(ansi.brightGreen(` Registered stack ${publishedStackId}`));
850
852
  lines.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
851
853
  if (watch)
852
854
  lines.push(...stackWatchTextLines(watch).map((line) => ` ${line}`));
@@ -963,9 +965,6 @@ export async function cmdStackSubmit(args, deps = {}) {
963
965
  await registerPr(opened[opened.length - 1].prNumber);
964
966
  }
965
967
  else {
966
- if (opened.length === 0) {
967
- throw new CommandError("No pull requests to register after submit.");
968
- }
969
968
  await registerPr(opened[0].prNumber);
970
969
  }
971
970
  const data = adoptData;
@@ -1057,7 +1056,7 @@ export async function cmdStackPublish(args, deps = {}) {
1057
1056
  ...(status.head ? [ansi.dim(` head ${status.head.sha} (tree ${status.head.tree ?? "unknown"})`)] : []),
1058
1057
  ...(status.pr ? [ansi.dim(` #${status.pr.number} → ${status.pr.base} ${status.pr.url}`)] : []),
1059
1058
  ...(status.stack
1060
- ? [ansi.dim(` stack ${status.stack.id} auto-patch ${status.stack.autoPatchOverride === false ? "off" : "not pinned off"} auto-land ${status.stack.autoLand ? "on" : "off"}`)]
1059
+ ? [ansi.dim(` stack ${status.stack.id} auto-patch ${status.stack.autoPatchOverride === null ? "default" : status.stack.autoPatchOverride ? "on" : "off"} auto-land ${status.stack.autoLand ? "on" : "off"}`)]
1061
1060
  : []),
1062
1061
  ]);
1063
1062
  return;
@@ -1077,7 +1076,7 @@ export async function cmdStackPublish(args, deps = {}) {
1077
1076
  await present("Stack", [
1078
1077
  ansi.brightGreen(` ${result.branch} ${moved} at ${result.head.sha.slice(0, 12)} (tree ${result.head.tree.slice(0, 12)})`),
1079
1078
  ansi.brightGreen(` #${result.pr.number} ${result.branch} → ${result.baseBranch} (${result.pr.created ? "opened" : "exists"}) ${result.pr.url}`),
1080
- ansi.brightGreen(` Registered stack ${result.stack.id} with Auto patch off`),
1079
+ ansi.brightGreen(` Registered stack ${result.stack.id}`),
1081
1080
  ...stackWatchTextLines(watch).map((line) => ` ${line}`),
1082
1081
  ]);
1083
1082
  }
@@ -1175,13 +1174,15 @@ export async function cmdStackWait(args, deps = {}) {
1175
1174
  }
1176
1175
  }
1177
1176
  export async function cmdStackWatch(args, deps = {}) {
1178
- const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]";
1177
+ const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]";
1179
1178
  const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
1180
1179
  const positional = [];
1181
1180
  const ignore = [];
1182
1181
  let until = "attention";
1183
1182
  let maxMinutes;
1184
1183
  let head;
1184
+ let bounceId;
1185
+ let afterFinishedAt;
1185
1186
  let json = false;
1186
1187
  for (let i = 0; i < args.length; i++) {
1187
1188
  const arg = args[i];
@@ -1212,6 +1213,17 @@ export async function cmdStackWatch(args, deps = {}) {
1212
1213
  if (!/^[0-9a-f]{7,40}$/i.test(head))
1213
1214
  throw fail("--head must be a commit SHA");
1214
1215
  }
1216
+ else if (flag === "--bounce-id") {
1217
+ bounceId = value();
1218
+ if (!/^[0-9a-z-]{1,64}$/i.test(bounceId))
1219
+ throw fail("--bounce-id must be a merge queue entry id");
1220
+ }
1221
+ else if (flag === "--after-finished-at") {
1222
+ const raw = value();
1223
+ if (Number.isNaN(Date.parse(raw)))
1224
+ throw fail("--after-finished-at must be an ISO timestamp");
1225
+ afterFinishedAt = new Date(raw).toISOString();
1226
+ }
1215
1227
  else if (arg.startsWith("-"))
1216
1228
  throw fail();
1217
1229
  else
@@ -1227,7 +1239,12 @@ export async function cmdStackWatch(args, deps = {}) {
1227
1239
  json,
1228
1240
  signal: deps.signal,
1229
1241
  ...(maxMinutes !== undefined ? { maxMs: maxMinutes * 60_000 } : {}),
1230
- ...(head ? { cursor: { stackId: stackId.trim().toLowerCase(), enrolledHeadSha: head.toLowerCase() } } : {}),
1242
+ ...(head || bounceId || afterFinishedAt ? { cursor: {
1243
+ stackId: stackId.trim().toLowerCase(),
1244
+ enrolledHeadSha: head ? head.toLowerCase() : null,
1245
+ ...(bounceId ? { bounceId } : {}),
1246
+ ...(afterFinishedAt ? { afterFinishedAt } : {}),
1247
+ } } : {}),
1231
1248
  });
1232
1249
  if (result.exitCode === STACK_WATCH_EXIT.landed)
1233
1250
  return;
@@ -1273,7 +1290,7 @@ export async function cmdStackStatus(args, deps = {}) {
1273
1290
  freshCursor: true,
1274
1291
  });
1275
1292
  if (asJson) {
1276
- console.log(JSON.stringify({ ...stack, held, repair, queueWait, watch }, null, 2));
1293
+ console.log(JSON.stringify({ ...stack, attention, held, repair, queueWait, watch }, null, 2));
1277
1294
  return;
1278
1295
  }
1279
1296
  const lines = formatStackHuman(stack, false);
@@ -1287,7 +1304,7 @@ export async function cmdStackStatus(args, deps = {}) {
1287
1304
  }
1288
1305
  if (queueWait && !attention)
1289
1306
  lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
1290
- lines.push(...[...stackHeldLine(held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
1307
+ lines.push(...[...stackHeldLine(attention ? null : held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
1291
1308
  await present("Stack status", lines);
1292
1309
  }
1293
1310
  /** Human summary lines for a `stack set` write, one per flag. */
@@ -1446,7 +1463,7 @@ export async function cmdStack(args, options = {}) {
1446
1463
  return cmdStackAdopt(rest);
1447
1464
  if (sub === "restack")
1448
1465
  return cmdStackRestack(rest);
1449
- if (sub === "land" || sub === "land-next")
1466
+ if (sub === "land")
1450
1467
  return cmdStackLand(rest);
1451
1468
  throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
1452
1469
  }
@@ -1,6 +1,4 @@
1
- export declare const LEGACY_STACK_META_REL: string;
2
1
  export declare function gitTopLevel(cwd?: string): string;
3
- export declare function gitCommonDir(cwd?: string): string;
4
2
  /**
5
3
  * Each worktree has its own authoring state.
6
4
  * Independent clones remain isolated even when they point at the same remote.
@@ -9,4 +7,3 @@ export declare function stableRepoId(cwd?: string): string;
9
7
  export declare function cliStacksRoot(): string;
10
8
  export declare function repoStateDir(cwd?: string, stacksRoot?: string): string;
11
9
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
12
- export declare function legacyStackMetaPath(cwd?: string): string;
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { mergestormHome } from "./config.js";
5
5
  import { CommandError } from "./errors.js";
6
6
  import { git } from "./git.js";
7
- export const LEGACY_STACK_META_REL = path.join(".mergestorm", "stack.json");
8
7
  /** Classify git "not a repo" failures once at the boundary; callers match on code. */
9
8
  function rethrowIfNotARepo(err) {
10
9
  const detail = err instanceof Error ? err.message : String(err);
@@ -21,25 +20,6 @@ export function gitTopLevel(cwd = process.cwd()) {
21
20
  rethrowIfNotARepo(err);
22
21
  }
23
22
  }
24
- export function gitCommonDir(cwd = process.cwd()) {
25
- let raw;
26
- try {
27
- raw = git(["rev-parse", "--git-common-dir"], cwd).trim();
28
- }
29
- catch (err) {
30
- rethrowIfNotARepo(err);
31
- }
32
- const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
33
- try {
34
- return realpathSync(absolute);
35
- }
36
- catch (err) {
37
- if (err?.code === "ENOENT") {
38
- throw new CommandError(`git common directory is missing (${absolute}); this is often a linked worktree whose main repository was deleted. Run \`mg stack reset --force\` to clear local pre-submit state.`, 1, "not_a_repo");
39
- }
40
- throw err;
41
- }
42
- }
43
23
  /**
44
24
  * Each worktree has its own authoring state.
45
25
  * Independent clones remain isolated even when they point at the same remote.
@@ -67,6 +47,3 @@ export function repoStateDir(cwd = process.cwd(), stacksRoot = cliStacksRoot())
67
47
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
68
48
  return path.join(repoStateDir(cwd, stacksRoot), "stack.json");
69
49
  }
70
- export function legacyStackMetaPath(cwd = process.cwd()) {
71
- return path.join(gitTopLevel(cwd), LEGACY_STACK_META_REL);
72
- }
@@ -190,7 +190,6 @@ export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land r
190
190
  export declare const AUTO_LAND_WAIT_ATTENTION_MS: number;
191
191
  export declare const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS: number;
192
192
  export declare function autoLandWaitLayer(stack: StackDto): StackLayerDto | undefined;
193
- export declare function autoLandWaitBlocker(stack: StackDto, layer: StackLayerDto, nowMs: number): string | null;
194
193
  export declare const MERGE_QUEUE_STALL_ATTENTION_MS: number;
195
194
  export type StackQueueWait = {
196
195
  prNumber: number;
@@ -221,22 +220,8 @@ export declare function stackRepair(stack: StackDto, attention: ClassifiedStackB
221
220
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
222
221
  /** Pair-gate attention plus live hard blocks on every other open layer. */
223
222
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
224
- attention: null;
225
- held: StackHeldBlocker;
226
- issues: StackBlocker[];
227
- currentCandidate: {
228
- prNumber: number;
229
- headSha: string | null;
230
- } | null;
231
- bounce: undefined;
232
- busy: StackAgentBusy[];
233
- agents: StackLayerAgents | null;
234
- repair: StackRepairHint | null;
235
- landGatePending: StackLandGatePending | null;
236
- queueWait: StackQueueWait | null;
237
- } | {
238
223
  attention: StackBlocker | null;
239
- held: null;
224
+ held: StackHeldBlocker | null;
240
225
  issues: StackBlocker[];
241
226
  currentCandidate: {
242
227
  prNumber: number;
@@ -185,9 +185,15 @@ function vortexImportantFindingsHeldAtHead(layer) {
185
185
  sameHead(layer.cycloneHandoff?.headSha, layer.headSha) &&
186
186
  CYCLONE_HELD_FINDINGS_REASONS.includes(layer.cycloneHandoff.reason);
187
187
  }
188
+ function seamApprovedAtHead(stack, layer) {
189
+ const member = (stack.unit?.members ?? []).find((entry) => entry.prNumber === layer.prNumber);
190
+ return member?.seamState?.trim().toLowerCase() === "approved" && sameHead(member.seamReviewedSha, layer.headSha);
191
+ }
188
192
  function vortexFindingsLeftForPerson(stack, layer) {
189
193
  if (layerAgentsBusy(layer).cyclone)
190
194
  return false;
195
+ if (seamApprovedAtHead(stack, layer))
196
+ return false;
191
197
  return vortexImportantFindingsHeldAtHead(layer) ||
192
198
  (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer) !== null);
193
199
  }
@@ -494,9 +500,6 @@ export function autoLandWaitLayer(stack) {
494
500
  function autoLandWaitOn(stack, layer) {
495
501
  return autoLandWaitLayer(stack)?.prNumber === layer.prNumber ? armedAutoLandWait(stack) : null;
496
502
  }
497
- export function autoLandWaitBlocker(stack, layer, nowMs) {
498
- return autoLandWaitNamed(stack, layer, nowMs)?.blocker ?? null;
499
- }
500
503
  function autoLandWaitNamed(stack, layer, nowMs) {
501
504
  const wait = autoLandWaitOn(stack, layer);
502
505
  if (!wait)
@@ -929,6 +932,15 @@ function landPrMissing(stack, candidate) {
929
932
  },
930
933
  };
931
934
  }
935
+ function layerBusy(layer) {
936
+ if (!layer)
937
+ return false;
938
+ const busy = layerAgentsBusy(layer);
939
+ return busy.cyclone || busy.vortex;
940
+ }
941
+ function sameBlocker(a, b) {
942
+ return a.prNumber === b.prNumber && a.blocker === b.blocker;
943
+ }
932
944
  /** Pair-gate attention plus live hard blocks on every other open layer. */
933
945
  export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
934
946
  const candidate = currentLayer(stack);
@@ -946,10 +958,12 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
946
958
  let attentionLayer;
947
959
  let bounce;
948
960
  const issues = [];
961
+ const found = [];
949
962
  const mergeStateUnknownPrs = new Set();
950
963
  const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
951
964
  for (const layer of open) {
952
965
  const selected = layer.prNumber === candidate?.prNumber;
966
+ const inScope = selected || !candidate || layer.position > candidate.position;
953
967
  let result = layerAttention(stack, layer, selected ? queue : [], cursor);
954
968
  let disarmed;
955
969
  if (!result.blocker && selected) {
@@ -978,10 +992,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
978
992
  result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, reason: "queue_stalled", bounceKind: null };
979
993
  }
980
994
  if (!result.blocker) {
981
- if (vortexRetryScheduled(layer) && (selected || !candidate || layer.position > candidate.position)) {
995
+ if (vortexRetryScheduled(layer) && inScope) {
982
996
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null, blocker: VORTEX_RETRY_SCHEDULED, bounceKind: null });
983
997
  }
984
- if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
998
+ if (mergeabilityPending(layer) && inScope) {
985
999
  mergeStateUnknownPrs.add(layer.prNumber);
986
1000
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
987
1001
  blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
@@ -990,6 +1004,9 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
990
1004
  }
991
1005
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
992
1006
  blocker: result.blocker, bounceKind: result.bounceKind };
1007
+ if (inScope || result.reason === "unit_abandoned" || layer === child || waitNamed) {
1008
+ found.push({ issue, reason: result.reason, layer, bounce: result.bounce });
1009
+ }
993
1010
  if (selected ||
994
1011
  result.reason === "unit_abandoned" ||
995
1012
  (!attention && (layer === child || waitNamed))) {
@@ -1001,7 +1018,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1001
1018
  issues.push({ prNumber: disarmed.bounceDetail?.prNumber ?? layer.prNumber,
1002
1019
  headSha: disarmed.bounceDetail?.headSha ?? null, blocker: mergeQueueBounceLabel(disarmed), bounceKind: result.bounceKind });
1003
1020
  }
1004
- else if (!candidate || layer.position > candidate.position)
1021
+ else if (inScope)
1005
1022
  issues.push(issue);
1006
1023
  }
1007
1024
  if (!attention) {
@@ -1011,6 +1028,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1011
1028
  attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting.blocker, bounceKind: null };
1012
1029
  attentionReason = waiting.reason;
1013
1030
  attentionLayer = unlisted;
1031
+ found.push({ issue: attention, reason: waiting.reason, layer: unlisted, bounce: undefined });
1014
1032
  }
1015
1033
  }
1016
1034
  const queued = queueWait && candidate && !queueWait.stalled
@@ -1025,13 +1043,15 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1025
1043
  attention = landMissing.attention;
1026
1044
  attentionReason = landMissing.reason;
1027
1045
  attentionLayer = undefined;
1046
+ found.unshift({ issue: landMissing.attention, reason: landMissing.reason, layer: undefined, bounce: undefined,
1047
+ repair: landMissing.repair });
1028
1048
  }
1029
1049
  const classified = attention && attentionReason
1030
1050
  ? { ...attention, reason: attentionReason }
1031
1051
  : null;
1032
1052
  const focus = attentionLayer ?? candidate;
1033
- const agents = focus ? layerAgents(focus) : null;
1034
- const busy = [];
1053
+ let agents = focus ? layerAgents(focus) : null;
1054
+ let busy = [];
1035
1055
  if (attention && agents) {
1036
1056
  for (const agent of ["cyclone", "vortex"]) {
1037
1057
  if (agents.busy[agent])
@@ -1045,17 +1065,39 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1045
1065
  const landGatePending = landLayer && landing && landGateIsPending(landing)
1046
1066
  ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
1047
1067
  : null;
1068
+ let held = null;
1069
+ let shownIssues = issues;
1070
+ let repair;
1048
1071
  if (busy.length && attention && classified && agentsCannotClear(classified.reason, stack, attentionLayer)) {
1049
- const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1050
- return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
1051
- repair: stackRepair(stack, classified, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
1072
+ held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1073
+ shownIssues = [attention, ...issues];
1074
+ repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait);
1075
+ attention = null;
1076
+ bounce = undefined;
1077
+ }
1078
+ else if (busy.length) {
1079
+ repair = null;
1080
+ attention = null;
1081
+ bounce = undefined;
1052
1082
  }
1053
- if (busy.length) {
1054
- return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
1055
- queueWait: queued };
1083
+ else {
1084
+ repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait);
1085
+ }
1086
+ if (!attention) {
1087
+ const next = found.find((item) => !layerBusy(item.layer));
1088
+ if (next) {
1089
+ attention = next.issue;
1090
+ repair = next.repair !== undefined
1091
+ ? next.repair
1092
+ : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait);
1093
+ bounce = next.bounce;
1094
+ if (next.layer)
1095
+ agents = layerAgents(next.layer);
1096
+ busy = [];
1097
+ shownIssues = shownIssues.filter((issue) => !sameBlocker(issue, next.issue));
1098
+ }
1056
1099
  }
1057
- return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
1058
- repair: landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait), landGatePending,
1100
+ return { attention, held, issues: shownIssues, currentCandidate, bounce, busy, agents, repair, landGatePending,
1059
1101
  queueWait: queued };
1060
1102
  }
1061
1103
  function mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention) {
@@ -19,8 +19,6 @@ export type StackMeta = {
19
19
  /** Index into `stacks` for create/submit. */
20
20
  active: number;
21
21
  };
22
- /** Exported only for legacy migration/tests. New state never uses this path. */
23
- export declare const STACK_META_REL: string;
24
22
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
25
23
  export declare function loadStackMeta(cwd?: string, stacksRoot?: string): Promise<StackMeta | null>;
26
24
  export declare function emptyStackMeta(trunk: string): StackMeta;
@@ -3,11 +3,10 @@
3
3
  * Stored outside repositories under ~/.mergestorm/stacks/<repo-id>/.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile, } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { CommandError, isCommandErrorCode } from "./errors.js";
9
- import { git } from "./git.js";
10
- import { cliStacksRoot, gitTopLevel, LEGACY_STACK_META_REL, legacyStackMetaPath, repoStateDir, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
9
+ import { cliStacksRoot, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
11
10
  const LOCAL_STACK_FLAGS = [
12
11
  "autoEnqueueWhenReady",
13
12
  "autoReviewOverride",
@@ -15,8 +14,6 @@ const LOCAL_STACK_FLAGS = [
15
14
  "autoResolveConflictsOverride",
16
15
  "autoFixCiOverride",
17
16
  ];
18
- /** Exported only for legacy migration/tests. New state never uses this path. */
19
- export const STACK_META_REL = LEGACY_STACK_META_REL;
20
17
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
21
18
  return resolveStackMetaPath(cwd, stacksRoot);
22
19
  }
@@ -64,17 +61,6 @@ function normalizeMeta(raw) {
64
61
  }
65
62
  return { version: 2, trunk: obj.trunk, stacks, active };
66
63
  }
67
- if (obj.version === 1) {
68
- if (!Array.isArray(obj.layers) || !obj.layers.every(isLayer)) {
69
- throw new Error("Stack authoring state has malformed legacy layers; run `mg stack reset --force` to clear it.");
70
- }
71
- return {
72
- version: 2,
73
- trunk: obj.trunk,
74
- stacks: [{ layers: obj.layers }],
75
- active: 0,
76
- };
77
- }
78
64
  throw new Error("Stack authoring state has an unknown version; run `mg stack reset --force` to clear it.");
79
65
  }
80
66
  function parseStateJson(raw) {
@@ -96,71 +82,17 @@ async function readIfExists(file) {
96
82
  throw err;
97
83
  }
98
84
  }
99
- async function removeLegacyFile(file, cwd = process.cwd(), force = false) {
100
- const top = gitTopLevel(cwd);
101
- const rel = path.relative(top, file);
102
- try {
103
- git(["rm", "-f", "--", rel], top);
104
- }
105
- catch (err) {
106
- // Fall back to a plain unlink only for untracked files; otherwise the
107
- // index entry would remain after the worktree copy is gone.
108
- if (git(["ls-files", "--", rel], top).trim() !== "") {
109
- if (!force) {
110
- throw new Error(`git rm failed for tracked ${rel}: ${err instanceof Error ? err.message : String(err)}`);
111
- }
112
- git(["update-index", "--force-remove", "--", rel], top);
113
- }
114
- await unlink(file);
115
- }
116
- try {
117
- await rmdir(path.dirname(file));
118
- }
119
- catch {
120
- // Leave a non-empty .mergestorm directory untouched.
121
- }
122
- }
123
85
  export async function loadStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
124
86
  const file = stackMetaPath(cwd, stacksRoot);
125
- const legacyFile = legacyStackMetaPath(cwd);
126
87
  const raw = await readIfExists(file);
127
- const legacyRaw = await readIfExists(legacyFile);
128
88
  if (raw !== null) {
129
89
  const meta = normalizeMeta(parseStateJson(raw));
130
90
  if (meta === null) {
131
91
  throw new CommandError("Stack authoring state is not an object; run `mg stack reset --force` to clear it.");
132
92
  }
133
- if (legacyRaw !== null) {
134
- const stateDir = repoStateDir(cwd, stacksRoot);
135
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
136
- const backup = path.join(stateDir, `legacy-conflict-${stamp}.json`);
137
- try {
138
- await mkdir(stateDir, { recursive: true, mode: 0o700 });
139
- await chmod(stateDir, 0o700);
140
- await copyFile(legacyFile, backup);
141
- await chmod(backup, 0o600);
142
- await removeLegacyFile(legacyFile, cwd);
143
- }
144
- catch (err) {
145
- throw new CommandError(`Failed to preserve legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
146
- }
147
- console.warn(`Warning: global stack state already existed; preserved legacy state at ${backup}.`);
148
- }
149
93
  return meta;
150
94
  }
151
- if (legacyRaw === null)
152
- return null;
153
- const migrated = normalizeMeta(parseStateJson(legacyRaw));
154
- if (!migrated)
155
- return null;
156
- try {
157
- await saveStackMeta(migrated, cwd, stacksRoot);
158
- await removeLegacyFile(legacyFile, cwd);
159
- }
160
- catch (err) {
161
- throw new CommandError(`Failed to migrate legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
162
- }
163
- return migrated;
95
+ return null;
164
96
  }
165
97
  export function emptyStackMeta(trunk) {
166
98
  return {
@@ -225,10 +157,8 @@ export async function saveStackMeta(meta, cwd = process.cwd(), stacksRoot = cliS
225
157
  /** Explicit recovery. Does not touch branches, PRs, or registered server stacks. */
226
158
  export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
227
159
  let stateFile;
228
- let legacyFile;
229
160
  try {
230
161
  stateFile = stackMetaPath(cwd, stacksRoot);
231
- legacyFile = legacyStackMetaPath(cwd);
232
162
  }
233
163
  catch (err) {
234
164
  // Outside a worktree / deleted main repo: reset is a no-op (nothing to clear).
@@ -238,14 +168,6 @@ export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacks
238
168
  throw err;
239
169
  }
240
170
  await rm(stateFile, { force: true });
241
- try {
242
- await removeLegacyFile(legacyFile, cwd, true);
243
- }
244
- catch (err) {
245
- const code = err?.code;
246
- if (code !== "ENOENT")
247
- throw err;
248
- }
249
171
  }
250
172
  export function appendLayer(meta, layer) {
251
173
  const current = activeLayers(meta);
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, LandedQueueReadError, readLandedQueueEntry } from "./api.js";
3
3
  import { isTransientReviewPollError } from "./commands/review-client.js";
4
+ import { stackWatchBackgroundCommand } from "./stack-watch-obligation.js";
4
5
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
5
6
  export const STACK_WATCH_MARKER = "MS-WATCH";
6
7
  export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
@@ -30,7 +31,9 @@ export function stackWatchAttentionKey(envelope) {
30
31
  return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${blockerWithoutElapsedMinutes(envelope.blocker)}`;
31
32
  }
32
33
  export function stackWatchAttentionLine(envelope) {
33
- return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}`;
34
+ const restart = envelope.cursor.bounceId
35
+ ? ` restart=${JSON.stringify(stackWatchBackgroundCommand(envelope.stackId, envelope.cursor))}` : "";
36
+ return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}${restart}`;
34
37
  }
35
38
  export function stackWatchLandedLine(envelope) {
36
39
  return `${STACK_WATCH_MARKER} LANDED stack=${envelope.stackId} reason=${envelope.watch.reason}`;
@@ -1,7 +1,10 @@
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
3
  export declare const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
4
- export declare function stackWatchBackgroundCommand(stackId: string): string;
4
+ export declare function stackWatchBackgroundCommand(stackId: string, cursor?: {
5
+ afterFinishedAt?: string | null;
6
+ bounceId?: string | null;
7
+ } | null): string;
5
8
  export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
6
9
  export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed" | "landing_unconfirmed";
7
10
  export type StackWatchNextArgs = {
@@ -1,7 +1,10 @@
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
2
  export const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
3
- export function stackWatchBackgroundCommand(stackId) {
4
- return `mg stack watch ${stackId}`;
3
+ function shellArg(value) {
4
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
5
+ }
6
+ export function stackWatchBackgroundCommand(stackId, cursor) {
7
+ return `mg stack watch ${shellArg(stackId)}${cursor?.bounceId ? ` --bounce-id ${shellArg(cursor.bounceId)}` : ""}${cursor?.afterFinishedAt ? ` --after-finished-at ${shellArg(cursor.afterFinishedAt)}` : ""}`;
5
8
  }
6
9
  const DONE_MESSAGES = {
7
10
  landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
@@ -53,7 +56,7 @@ export function stackWatchObligation(input) {
53
56
  : input.status === "failed"
54
57
  ? "failed"
55
58
  : input.attention ? "attention" : input.unread ? "unread" : "open";
56
- const background = stackWatchBackgroundCommand(input.stackId);
59
+ const background = stackWatchBackgroundCommand(input.stackId, input.cursor);
57
60
  const parts = [
58
61
  STACK_WATCH_NOT_DONE_SENTENCE,
59
62
  `If your host has a confirmed notification that resumes this task, run \`${background}\` as a background command, notify on output matching ${STACK_WATCH_NOTIFY_PATTERN}, and end your turn. The command monitors the stack; it cannot wake an agent by itself, and a process left running is not a notification. Without a confirmed notification, keep using stack_wait with the returned cursor, or report that automatic follow-up is unavailable before ending the turn.`,
@@ -66,7 +69,7 @@ export function stackWatchObligation(input) {
66
69
  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.`);
67
70
  }
68
71
  else if (reason === "attention" && input.attention) {
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.`);
72
+ 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.${input.cursor?.bounceId ? ` If you handle it without a push (for example by turning Auto land back on), start \`${background}\` so this bounce is not reported again.` : ""}`);
70
73
  }
71
74
  else if (reason === "unread") {
72
75
  parts.push("This tool did not read the stack's state; stack_wait will.");
@@ -154,8 +154,9 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
154
154
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
155
155
  const held = busy[0];
156
156
  const shown = attention ?? named;
157
+ const waitingNamed = attention ? null : named;
157
158
  lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending, queueWait,
158
- actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
159
+ actAfter: waitingNamed?.actAfter ?? null, waitingOn: waitingNamed?.waitingOn ?? [],
159
160
  blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
160
161
  prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
161
162
  headSha: shown ? shown.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
package/dist/ui/banner.js CHANGED
@@ -221,6 +221,9 @@ const WHATS_NEW = {
221
221
  "0.3.61": [
222
222
  "Watch names a job Cyclone's push skips",
223
223
  ],
224
+ "0.3.62": [
225
+ "Watch wakes for a blocker on any layer",
226
+ ],
224
227
  };
225
228
  function densityFromVariant(variant) {
226
229
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
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",
@@ -25,10 +25,14 @@ The CLI watcher monitors the stack but cannot wake an agent by itself. Use the b
25
25
  - `MS-WATCH ATTENTION failed stack=<id> error="<message>"` (exit 3): a read failed and its one retry failed too.
26
26
  - `MS-WATCH LANDED stack=<id> reason=landed` (exit 0): the stack is confirmed landed. `closed`, `archived`, and `not_found` instead emit `MS-WATCH ATTENTION stack=<id> reason=<reason>` (exit 3); follow "Not done until landed".
27
27
  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.
28
- 3. When the host resumes 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.
29
- 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.
28
+ 3. When the host resumes 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. When the ATTENTION line ends with `restart="mg stack watch <id> --bounce-id <id> --after-finished-at <time>"` (a merge queue bounce), start the watcher again with that command, adding `--head <sha>` after a push; without those flags a bounce you handled without a push (for example by turning Auto land back on) is reported again at once. On `MS-WATCH ATTENTION failed`, the watcher already retried once: tell the human the stack is not landed and is no longer being watched.
29
+ 4. When the human has taken one blocker over and asked you to keep watching the stack, 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. Attention names one blocker at a time, so while the ignored blocker stands, blockers on higher layers stay hidden too; the next blocked layer wakes you only after the ignored blocker, its PR, or its head changes.
30
30
  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.
31
31
 
32
+ ## Attention on any layer
33
+
34
+ Attention covers the whole stack, not only `currentCandidate`. It names the lowest open layer, a parked one included, that has a blocker an agent must fix while no Vortex or Cyclone run is working on that layer. On a layer with auto-patch off, request_changes from Vortex at the live head is `Vortex findings need a person`, whichever layer it is on. There is one attention at a time: fix it, push, and restart the watch; the next read names the next blocked layer at once. Vortex findings that do not stop Auto land (Important findings Cyclone never took, Info notes) are not attention and the watch does not report them.
35
+
32
36
  ## Scope and enrollment
33
37
 
34
38
  Watch only stacks the human named. If no unambiguous stack ID is available, ask the human to identify it; do not discover and enroll other stacks. Keep a separate cursor for each named stack.