mergestorm 0.3.26 → 0.3.28

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.26 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.28 │
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: stack results say the watch ends only at land │
53
+ │ ▀ New: mg dismiss records an audited finding dismissal │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -96,8 +96,21 @@ mergestorm stack restack <stack-id> Restack descendants
96
96
  mergestorm stack land <stack-id> Land / promote (into review unit when present)
97
97
  mergestorm stack wait <stack-id> [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)
98
98
  mergestorm stack reset --force Clear local authoring state (not branches/PRs)
99
+ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview List a Vortex review's finding ids and the seam gate
100
+ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --reason <text> Dismiss verified-wrong findings (`--all` for the whole review, `--evidence <url>`, `--json`)
99
101
  ```
100
102
 
103
+ ### Dismissing a finding
104
+
105
+ Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it against the code. It is recorded server side with your GitHub login, the reason, the evidence URL, the scope and the time. Rules:
106
+
107
+ - You need write access to the repository on GitHub, and the repository must be monitored by your Mergestorm account or the PR must be in one of your stacks.
108
+ - `--head` must be the PR's live head and `--review` a Vortex review made at that head. A moved head, another review, or a finding id that is not in that review is refused, and nothing is written.
109
+ - Retrying the same dismissal is safe; it reports the earlier record.
110
+ - The seam gate clears only when every finding of that integration review is dismissed and the review is still the current seam verdict. CI, other reviews and Auto land policy still apply.
111
+ - Vortex does not raise a dismissed finding again on the same diff (the same head, or a restack with an unchanged diff).
112
+ - With `--json`, the result carries `gate` and, for a stack PR, `watch.next` (the next `stack_wait` call).
113
+
101
114
  `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. On review-unit stacks, `stack land` **promotes** the tip into the unit (same gates as the dashboard); otherwise it lands the bottom open PR. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`). Unattended landing is `mg queue add`.
102
115
 
103
116
  `stack create` checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and records `{ branch, parentBranch }` in CLI-managed state under **`~/.mergestorm/stacks/<repo-id>/stack.json`**. The CLI updates this automatically; never edit it. Each worktree has its own authoring state; independent clones stay isolated too. Creating onto trunk with a non-empty active stack starts a new one. `--trunk` only sets trunk metadata — it does **not** change the parent; use `--onto main` (etc.) for a fresh stack from trunk.
package/dist/api.d.ts CHANGED
@@ -267,3 +267,62 @@ export declare function enqueueStack(stackId: string, cfg?: Config): Promise<unk
267
267
  export declare function cancelMergeQueueEntry(entryId: string, cfg?: Config): Promise<unknown>;
268
268
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
269
269
  export declare function listThreads(limit?: number, cfg?: Config): Promise<ThreadListItem[]>;
270
+ export type PrFindingDismissInput = {
271
+ owner: string;
272
+ repo: string;
273
+ prNumber: number;
274
+ headSha: string;
275
+ reviewId: number;
276
+ findingIds?: string[];
277
+ scope?: "findings" | "review";
278
+ reason?: string;
279
+ evidenceUrl?: string;
280
+ preview?: boolean;
281
+ };
282
+ export type PrDismissedFinding = {
283
+ finding_id: string;
284
+ finding_key: string;
285
+ path: string | null;
286
+ line: number | null;
287
+ title: string;
288
+ };
289
+ export type PrFindingDismissResult = {
290
+ status: "dismissed" | "preview";
291
+ owner: string;
292
+ repo: string;
293
+ pr_number: number;
294
+ head_sha: string;
295
+ review_id: number;
296
+ review_kind: "seam" | "core";
297
+ scope: "findings" | "review";
298
+ dismissed: PrDismissedFinding[];
299
+ already_dismissed: Array<PrDismissedFinding & {
300
+ actor_login: string;
301
+ dismissed_at: string;
302
+ }>;
303
+ remaining: PrDismissedFinding[];
304
+ all_dismissed: boolean;
305
+ gate: {
306
+ seam: {
307
+ state: string;
308
+ reviewed_sha: string | null;
309
+ review_id: number | null;
310
+ cleared: boolean;
311
+ blocking: boolean;
312
+ } | null;
313
+ other_gates: "unchanged";
314
+ };
315
+ stack_id: string | null;
316
+ };
317
+ export type PrFindingDismissOutcome = {
318
+ ok: true;
319
+ result: PrFindingDismissResult;
320
+ } | {
321
+ ok: false;
322
+ status: number;
323
+ error: string;
324
+ message: string;
325
+ };
326
+ export declare function dismissPrFindings(input: PrFindingDismissInput, cfg?: Config, opts?: {
327
+ signal?: AbortSignal;
328
+ }): Promise<PrFindingDismissOutcome>;
package/dist/api.js CHANGED
@@ -597,3 +597,43 @@ export async function listThreads(limit = 20, cfg) {
597
597
  const items = body.items;
598
598
  return Array.isArray(items) ? items : [];
599
599
  }
600
+ export async function dismissPrFindings(input, cfg, opts) {
601
+ const resolved = cfg ?? (await loadConfig());
602
+ const json = {
603
+ owner: input.owner.trim(),
604
+ repo: input.repo.trim(),
605
+ pr_number: input.prNumber,
606
+ head_sha: input.headSha.trim(),
607
+ review_id: input.reviewId,
608
+ ...(input.scope ? { scope: input.scope } : {}),
609
+ ...(input.findingIds && input.findingIds.length > 0 ? { finding_ids: input.findingIds } : {}),
610
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
611
+ ...(input.evidenceUrl ? { evidence_url: input.evidenceUrl } : {}),
612
+ ...(input.preview ? { preview: true } : {}),
613
+ };
614
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, "/api/v1/stacks/pr-review/dismiss", {
615
+ method: "POST",
616
+ json,
617
+ signal: opts?.signal,
618
+ });
619
+ if (status === 429) {
620
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", {
621
+ retryAfterSeconds,
622
+ });
623
+ }
624
+ if (status === 200)
625
+ return { ok: true, result: body };
626
+ const rejection = (body ?? {});
627
+ if (status === 404 && typeof rejection.error !== "string") {
628
+ throw new CommandError("Finding dismissal is not available on this server yet. Deploy the API update.", REVIEW_EXIT.failed, "not_found");
629
+ }
630
+ if (typeof rejection.error === "string") {
631
+ return {
632
+ ok: false,
633
+ status,
634
+ error: rejection.error,
635
+ message: typeof rejection.message === "string" ? rejection.message : rejection.error,
636
+ };
637
+ }
638
+ throw new CommandError(`Failed to dismiss findings (HTTP ${status}): ${JSON.stringify(body)}`, REVIEW_EXIT.failed, "review_failed");
639
+ }
@@ -18,10 +18,6 @@ export declare const BEARER_SETTINGS: readonly [{
18
18
  readonly key: "review_unit_land_prs_enabled";
19
19
  readonly flag: "--review-unit-land";
20
20
  readonly label: "Review Unit land PRs";
21
- }, {
22
- readonly key: "cyclone_review_unit_land_prs_enabled";
23
- readonly flag: "--cyclone-review-unit-land";
24
- readonly label: "Cyclone Review Unit land PRs";
25
21
  }, {
26
22
  readonly key: "cyclone_skip_ci_enabled";
27
23
  readonly flag: "--cyclone-skip-ci";
@@ -91,4 +87,4 @@ export type BearerSettingsValues = {
91
87
  values: readonly (infer Value)[];
92
88
  } ? Value : boolean;
93
89
  };
94
- export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
90
+ export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
@@ -4,7 +4,6 @@ export const BEARER_SETTINGS = [
4
4
  { key: "vortex_show_thinking_traces", flag: "--vortex-thinking", label: "Vortex thinking traces" },
5
5
  { key: "repo_overview_enabled", flag: "--repo-overview", label: "Repo overview" },
6
6
  { key: "review_unit_land_prs_enabled", flag: "--review-unit-land", label: "Review Unit land PRs" },
7
- { key: "cyclone_review_unit_land_prs_enabled", flag: "--cyclone-review-unit-land", label: "Cyclone Review Unit land PRs" },
8
7
  { key: "cyclone_skip_ci_enabled", flag: "--cyclone-skip-ci", label: "Skip CI on Cyclone commits" },
9
8
  { key: "cyclone_patch_unverified_languages", flag: "--cyclone-patch-unverified", label: "Patch languages we cannot typecheck" },
10
9
  { key: "vortex_auto_overflow_enabled", flag: "--vortex-auto-overflow", label: "Vortex auto overflow" },
@@ -1,11 +1,11 @@
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, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type SettingsResponse, type SettingsWritableKey, type StackDto, type StackPolicyPatch, 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, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, 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 SettingsResponse, type SettingsWritableKey, type StackDto, type StackPolicyPatch, 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, 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";
7
7
  export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, type StackAgentBusy, type StackBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
11
11
  export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
@@ -1,11 +1,11 @@
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, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, } 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, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, } 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, 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";
7
7
  export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, } from "../stack-blockers.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, } from "../stack-blockers.js";
11
11
  export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
@@ -27,7 +27,7 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
27
27
  /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
28
28
  export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
29
  /** Human labels for the Config tab and the static `settings` printout. */
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_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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
31
31
  /** Config rows in a stable order: writable toggles, then connected flags. */
32
32
  export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
33
33
  /**
@@ -0,0 +1,15 @@
1
+ import { type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult } from "../api.js";
2
+ import { type Config } from "../config.js";
3
+ import { type StackWatchObligation } from "../stack-watch-obligation.js";
4
+ export declare const DISMISS_USAGE: string;
5
+ export type ParsedDismissArgs = PrFindingDismissInput & {
6
+ json: boolean;
7
+ };
8
+ export declare function parseDismissArgs(args: string[]): ParsedDismissArgs;
9
+ export declare function dismissWatch(result: PrFindingDismissResult): StackWatchObligation | null;
10
+ export declare function formatDismissResult(result: PrFindingDismissResult, watch: StackWatchObligation | null): string;
11
+ export type DismissDeps = {
12
+ loadConfig?: () => Promise<Config>;
13
+ dismiss?: (input: PrFindingDismissInput, cfg: Config) => Promise<PrFindingDismissOutcome>;
14
+ };
15
+ export declare function cmdDismiss(args: string[], deps?: DismissDeps): Promise<void>;
@@ -0,0 +1,145 @@
1
+ import { dismissPrFindings, } from "../api.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { CommandError, REVIEW_EXIT } from "../errors.js";
4
+ import { stackWatchObligation } from "../stack-watch-obligation.js";
5
+ import { parseAdoptTarget, withWatch } from "./stack.js";
6
+ export const DISMISS_USAGE = [
7
+ "usage: mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id>",
8
+ " (--finding <id> [--finding <id>…] | --all) --reason <text> [--evidence <url>] [--json]",
9
+ " mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview [--json]",
10
+ ].join("\n");
11
+ function usage(message) {
12
+ return new CommandError(message ? `${message}\n${DISMISS_USAGE}` : DISMISS_USAGE, REVIEW_EXIT.usage, "usage");
13
+ }
14
+ export function parseDismissArgs(args) {
15
+ const target = [];
16
+ const findingIds = [];
17
+ let headSha = "";
18
+ let reviewId;
19
+ let reason;
20
+ let evidenceUrl;
21
+ let all = false;
22
+ let preview = false;
23
+ let json = false;
24
+ const value = (flag, raw) => {
25
+ if (raw === undefined || raw.startsWith("--"))
26
+ throw usage(`${flag} needs a value`);
27
+ return raw;
28
+ };
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ if (arg === "--head")
32
+ headSha = value(arg, args[++i]).trim().toLowerCase();
33
+ else if (arg === "--review") {
34
+ const raw = value(arg, args[++i]).trim();
35
+ if (!/^[1-9]\d*$/.test(raw) || !Number.isSafeInteger(Number(raw)))
36
+ throw usage("--review needs the numeric GitHub review id");
37
+ reviewId = Number(raw);
38
+ }
39
+ else if (arg === "--finding")
40
+ findingIds.push(...value(arg, args[++i]).split(",").map((id) => id.trim()).filter(Boolean));
41
+ else if (arg === "--all")
42
+ all = true;
43
+ else if (arg === "--reason")
44
+ reason = value(arg, args[++i]);
45
+ else if (arg === "--evidence")
46
+ evidenceUrl = value(arg, args[++i]).trim();
47
+ else if (arg === "--preview")
48
+ preview = true;
49
+ else if (arg === "--json")
50
+ json = true;
51
+ else if (arg.startsWith("--"))
52
+ throw usage(`unknown flag ${arg}`);
53
+ else
54
+ target.push(arg);
55
+ }
56
+ let parsedTarget;
57
+ try {
58
+ parsedTarget = parseAdoptTarget(target);
59
+ }
60
+ catch {
61
+ throw usage("name the PR as <owner/repo>#<n>");
62
+ }
63
+ if (!/^[0-9a-f]{40}$/.test(headSha))
64
+ throw usage("--head needs the full 40-character PR head SHA");
65
+ if (reviewId === undefined)
66
+ throw usage("--review is required");
67
+ if (!preview) {
68
+ if (all && findingIds.length > 0)
69
+ throw usage("pass --finding or --all, not both");
70
+ if (!all && findingIds.length === 0)
71
+ throw usage("pass --finding <id> for each finding, or --all for the whole review");
72
+ if (!reason?.trim())
73
+ throw usage("--reason is required: say why the finding is wrong or not actionable");
74
+ }
75
+ return {
76
+ ...parsedTarget,
77
+ headSha,
78
+ reviewId,
79
+ findingIds: all ? [] : findingIds,
80
+ scope: all ? "review" : "findings",
81
+ ...(reason !== undefined ? { reason } : {}),
82
+ ...(evidenceUrl ? { evidenceUrl } : {}),
83
+ preview,
84
+ json,
85
+ };
86
+ }
87
+ export function dismissWatch(result) {
88
+ if (!result.stack_id)
89
+ return null;
90
+ return stackWatchObligation({ stackId: result.stack_id, terminal: null, unread: true, freshCursor: true });
91
+ }
92
+ function findingLine(finding) {
93
+ const where = finding.path ? `${finding.path}${finding.line ? `:${finding.line}` : ""}` : "review body";
94
+ return ` ${finding.finding_id} ${where} ${finding.title}`;
95
+ }
96
+ export function formatDismissResult(result, watch) {
97
+ const lines = [];
98
+ const pr = `${result.owner}/${result.repo}#${result.pr_number}`;
99
+ const kind = result.review_kind === "seam" ? "seam review" : "Vortex review";
100
+ if (result.status === "preview") {
101
+ lines.push(`${pr} ${kind} ${result.review_id} at ${result.head_sha.slice(0, 7)}: ${result.remaining.length} open finding(s)`);
102
+ lines.push(...result.remaining.map(findingLine));
103
+ }
104
+ else {
105
+ lines.push(`${pr} ${kind} ${result.review_id} at ${result.head_sha.slice(0, 7)}`);
106
+ if (result.dismissed.length)
107
+ lines.push("Dismissed:", ...result.dismissed.map(findingLine));
108
+ if (result.already_dismissed.length) {
109
+ lines.push("Already dismissed:", ...result.already_dismissed.map((f) => `${findingLine(f)} (by @${f.actor_login})`));
110
+ }
111
+ if (result.remaining.length)
112
+ lines.push("Still open:", ...result.remaining.map(findingLine));
113
+ }
114
+ const seam = result.gate.seam;
115
+ if (seam) {
116
+ lines.push(seam.cleared
117
+ ? "Seam gate: cleared (approved at this head). CI, other reviews and Auto land policy still apply."
118
+ : seam.blocking
119
+ ? `Seam gate: still blocking (seam_state=${seam.state}).`
120
+ : `Seam gate: not blocking (seam_state=${seam.state}).`);
121
+ }
122
+ if (watch?.next)
123
+ lines.push(`Next: ${watch.next.command}`);
124
+ return lines.join("\n");
125
+ }
126
+ export async function cmdDismiss(args, deps = {}) {
127
+ const parsed = parseDismissArgs(args);
128
+ const cfg = await (deps.loadConfig ?? loadConfig)();
129
+ const { json, ...input } = parsed;
130
+ const outcome = await (deps.dismiss ?? ((i, c) => dismissPrFindings(i, c)))(input, cfg);
131
+ if (!outcome.ok) {
132
+ if (json) {
133
+ console.log(JSON.stringify({ error: { code: outcome.error, message: outcome.message } }, null, 2));
134
+ process.exitCode = REVIEW_EXIT.failed;
135
+ return;
136
+ }
137
+ throw new CommandError(`Dismissal refused (${outcome.error}): ${outcome.message}`, REVIEW_EXIT.failed, "review_failed");
138
+ }
139
+ const watch = dismissWatch(outcome.result);
140
+ if (json) {
141
+ console.log(JSON.stringify(withWatch(outcome.result, watch), null, 2));
142
+ return;
143
+ }
144
+ console.log(formatDismissResult(outcome.result, watch));
145
+ }
@@ -3,6 +3,7 @@ import { CommandError } from "../errors.js";
3
3
  import { cmdBranches, cmdChain } from "./branches.js";
4
4
  import { canBrowse, openTabsBrowser } from "./browse.js";
5
5
  import { cmdCredits } from "./credits.js";
6
+ import { cmdDismiss } from "./dismiss.js";
6
7
  import { cmdJobs, cmdThread } from "./jobs.js";
7
8
  import { cmdLogin } from "./login.js";
8
9
  import { cmdLogout } from "./logout.js";
@@ -79,6 +80,21 @@ export const COMMAND_REGISTRY = [
79
80
  });
80
81
  },
81
82
  },
83
+ {
84
+ name: "dismiss",
85
+ summary: "Dismiss Vortex findings you verified are wrong (audited)",
86
+ usage: [
87
+ "mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview",
88
+ " Lists the review's findings (ids) and the seam gate; writes nothing",
89
+ "mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --reason <text>",
90
+ " [--finding <id>]… | --all (every finding of that review) [--evidence <url>] [--json]",
91
+ " Needs write access on GitHub; refuses a moved head, another review or an unknown id",
92
+ " Clears the seam gate only when every finding of that seam review is dismissed",
93
+ ],
94
+ async run(args) {
95
+ await cmdDismiss(args);
96
+ },
97
+ },
82
98
  {
83
99
  name: "status",
84
100
  summary: "Job panel (id) or Status / Usage / Jobs tabs",
@@ -165,7 +181,7 @@ export const COMMAND_REGISTRY = [
165
181
  "mergestorm settings [--json] Read the automation toggles (Config tab on a TTY)",
166
182
  " [--auto-review on|off] [--auto-patch on|off] [--vortex-thinking on|off]",
167
183
  " [--repo-overview on|off] [--review-unit-land on|off]",
168
- " [--cyclone-review-unit-land on|off] [--cyclone-skip-ci on|off]",
184
+ " [--cyclone-skip-ci on|off]",
169
185
  " [--cyclone-patch-unverified on|off] Patch languages we cannot typecheck",
170
186
  " [--vortex-seam on|off] [--vortex-skip-all-clear on|off]",
171
187
  " [--auto-land on|off] Auto land default for new stacks",
@@ -3,7 +3,7 @@ import { type SettingsPatch, type SettingsResponse } from "../api.js";
3
3
  * CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
4
4
  * read-only on the API and deliberately have no flag here.
5
5
  */
6
- export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
6
+ export declare const SETTINGS_FLAGS: Record<string, "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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
7
7
  type IgnoreBotOperation = {
8
8
  action: "add" | "remove";
9
9
  login: string;
@@ -1,4 +1,4 @@
1
- import { type StackRepairHint } from "../stack-blockers.js";
1
+ import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
2
2
  import { type StackWatchObligation } from "../stack-watch-obligation.js";
3
3
  import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
@@ -9,6 +9,7 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
9
9
  import { gitTopLevel } from "../repo-identity.js";
10
10
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
11
11
  import { pollStackWatch } from "../stack-watch.js";
12
+ export declare function stackHeldLine(held: StackHeldBlocker | null | undefined): string[];
12
13
  export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
13
14
  export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
14
15
  export declare function openStackWatch(stackId: string, opts?: {
@@ -16,6 +16,12 @@ import { runLineTabsBrowser } from "../ui/line-tabs.js";
16
16
  import { present } from "../ui/present.js";
17
17
  import { canBrowse } from "./browse.js";
18
18
  import { openHelpBrowser } from "./help.js";
19
+ export function stackHeldLine(held) {
20
+ if (!held)
21
+ return [];
22
+ const agents = held.waitingOn.map((agent) => agent === "cyclone" ? "Cyclone" : "Vortex").join(" and ");
23
+ return [`Held: #${held.prNumber} ${held.blocker}. Plan the fix; act once ${agents} ${held.waitingOn.length > 1 ? "are" : "is"} idle and the watch returns attention.`];
24
+ }
19
25
  export function stackRepairLine(repair) {
20
26
  if (!repair)
21
27
  return [];
@@ -895,7 +901,7 @@ export async function cmdStackWait(args, deps = {}) {
895
901
  ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
896
902
  }, null, 2)
897
903
  : [
898
- `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
904
+ `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null, envelope.actAfter ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
899
905
  ...stackRepairLine(envelope.repair),
900
906
  ...stackWatchTextLines(envelope.watch),
901
907
  ].join("\n"));
@@ -939,7 +945,7 @@ export async function cmdStackStatus(args, deps = {}) {
939
945
  console.log(watch.message);
940
946
  throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
941
947
  }
942
- const { attention, currentCandidate, repair } = stackBlockers(stack);
948
+ const { attention, held, currentCandidate, repair } = stackBlockers(stack);
943
949
  const watch = stackWatchObligation({
944
950
  stackId: stack.id,
945
951
  terminal: stackTerminalReason(stack),
@@ -948,7 +954,7 @@ export async function cmdStackStatus(args, deps = {}) {
948
954
  freshCursor: true,
949
955
  });
950
956
  if (asJson) {
951
- console.log(JSON.stringify({ ...stack, repair, watch }, null, 2));
957
+ console.log(JSON.stringify({ ...stack, held, repair, watch }, null, 2));
952
958
  return;
953
959
  }
954
960
  const lines = formatStackHuman(stack, false);
@@ -961,7 +967,7 @@ export async function cmdStackStatus(args, deps = {}) {
961
967
  (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
962
968
  (layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
963
969
  }
964
- lines.push(...[...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
970
+ lines.push(...[...stackHeldLine(held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
965
971
  await present("Stack status", lines);
966
972
  }
967
973
  /** Human summary lines for a `stack set` write, one per flag. */
@@ -12,6 +12,11 @@ export type StackAgentBusy = {
12
12
  agent: "vortex" | "cyclone";
13
13
  blocker: string;
14
14
  };
15
+ export type StackBusyAgent = StackAgentBusy["agent"];
16
+ export type StackHeldBlocker = StackBlocker & {
17
+ actAfter: "agents_idle";
18
+ waitingOn: StackBusyAgent[];
19
+ };
15
20
  export type StackLayerAgents = {
16
21
  prNumber: number;
17
22
  headSha: string | null;
@@ -42,6 +47,20 @@ export type StackRepairHint = {
42
47
  branch: string;
43
48
  failingCheck: string | null;
44
49
  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: "auto_land_off";
59
+ prNumber: number;
60
+ headSha: string | null;
61
+ branch: string;
62
+ bounceKind: string;
63
+ steps: string;
45
64
  };
46
65
  export type StackLandGatePending = {
47
66
  prNumber: number;
@@ -54,14 +73,32 @@ export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
54
73
  export declare function isMgParkBase(branch: string | null | undefined): boolean;
55
74
  /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
56
75
  export declare function boundDirty(layer: StackLayerDto): boolean;
76
+ export declare function mergeabilityPending(layer: StackLayerDto): boolean;
57
77
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
58
78
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
79
+ export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
80
+ export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
59
81
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
60
82
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
83
+ export declare function agentsCannotClear(blocker: string): boolean;
61
84
  export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
62
85
  /** Pair-gate attention plus live hard blocks on every other open layer. */
63
86
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
87
+ attention: null;
88
+ held: StackHeldBlocker;
89
+ issues: StackBlocker[];
90
+ currentCandidate: {
91
+ prNumber: number;
92
+ headSha: string | null;
93
+ } | null;
94
+ bounce: undefined;
95
+ busy: StackAgentBusy[];
96
+ agents: StackLayerAgents | null;
97
+ repair: StackRepairHint | null;
98
+ landGatePending: StackLandGatePending | null;
99
+ } | {
64
100
  attention: StackBlocker | null;
101
+ held: null;
65
102
  issues: StackBlocker[];
66
103
  currentCandidate: {
67
104
  prNumber: number;
@@ -74,4 +111,4 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
74
111
  landGatePending: StackLandGatePending | null;
75
112
  };
76
113
  /** Compact contract text shared by status and wait summaries. */
77
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null): string;
114
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null): string;
@@ -30,6 +30,12 @@ export function boundDirty(layer) {
30
30
  return (!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
31
31
  (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty");
32
32
  }
33
+ export function mergeabilityPending(layer) {
34
+ if (isMgParkBase(layer.parentBranch))
35
+ return false;
36
+ return layer.mergeable == null && !!layer.headSha && layer.mergeableHeadSha === layer.headSha &&
37
+ layer.mergeableState?.toLowerCase() === "pending";
38
+ }
33
39
  export function sameHead(a, b) {
34
40
  const left = a?.trim().toLowerCase();
35
41
  const right = b?.trim().toLowerCase();
@@ -45,6 +51,13 @@ export function currentLayer(stack) {
45
51
  return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
46
52
  (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
47
53
  }
54
+ export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
55
+ export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
56
+ function quotaSkippedReview(layer) {
57
+ const review = layer.vortexReview;
58
+ return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
59
+ sameHead(review.head_sha, layer.headSha);
60
+ }
48
61
  function seamGateBlocker(stack, layer) {
49
62
  const members = stack.unit?.members ?? [];
50
63
  if (layer.prNumber === stack.unit?.landPrNumber)
@@ -59,6 +72,8 @@ function seamGateBlocker(stack, layer) {
59
72
  return "Seam review failed";
60
73
  if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
61
74
  return "Seam findings";
75
+ if (seamState === "pending" && !layerAgentsBusy(layer).vortex)
76
+ return SEAM_REVIEW_NOT_RUNNING;
62
77
  return null;
63
78
  }
64
79
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
@@ -92,12 +107,17 @@ function layerAttention(stack, layer, queue, cursor) {
92
107
  const name = layer.checks?.failingName?.trim();
93
108
  return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
94
109
  }
110
+ if (layer.agentRuns?.some((run) => run.agent === "cyclone" && run.status === "failed" &&
111
+ sameHead(run.sha, layer.headSha)))
112
+ return hardBlock("Cyclone failed");
95
113
  const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
96
114
  ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
97
115
  if (tempest)
98
116
  return hardBlock(`Tempest ${tempest.status}`);
99
117
  if (layer.vortexStatus === "failed")
100
118
  return hardBlock("Review failed");
119
+ if (quotaSkippedReview(layer))
120
+ return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
101
121
  const seam = seamGateBlocker(stack, layer);
102
122
  if (seam)
103
123
  return hardBlock(seam);
@@ -120,6 +140,27 @@ function layerAttention(stack, layer, queue, cursor) {
120
140
  return none;
121
141
  return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
122
142
  }
143
+ const AUTO_LAND_DISARMING_BOUNCE_KINDS = ["merge_failed", "gh_error", "ci_timeout"];
144
+ const AUTO_LAND_OFF_BLOCKER_PREFIX = "Auto land off after ";
145
+ function isDisarmingBounce(entry) {
146
+ const kind = entry?.bounceDetail?.kind;
147
+ return entry?.state === "bounced" && !!kind && AUTO_LAND_DISARMING_BOUNCE_KINDS.includes(kind);
148
+ }
149
+ function disarmingBounce(stack, queue) {
150
+ if (stack.archivedAt || stack.autoEnqueueWhenReady !== false)
151
+ return undefined;
152
+ const byRecency = [...queue].sort((a, b) => (Date.parse(b.enqueuedAt) || 0) - (Date.parse(a.enqueuedAt) || 0) ||
153
+ (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0));
154
+ if (stack.autoLandOff === undefined) {
155
+ return isDisarmingBounce(byRecency[0]) ? byRecency[0] : undefined;
156
+ }
157
+ if (stack.autoLandOff?.reason !== "bounced")
158
+ return undefined;
159
+ const recorded = queue.find((entry) => entry.id === stack.autoLandOff?.entryId);
160
+ if (isDisarmingBounce(recorded))
161
+ return recorded;
162
+ return undefined;
163
+ }
123
164
  function isTerminalLayer(layer) {
124
165
  return layer.state === "merged" || layer.state === "closed";
125
166
  }
@@ -156,15 +197,27 @@ export function conflictRepairSteps(branch, liveParent) {
156
197
  }
157
198
  return `Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve the conflicts, run the tests, then an ordinary git push (no force). Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
158
199
  }
200
+ function landPrLiveParent(stack, layer) {
201
+ const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
202
+ return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
203
+ }
204
+ export function agentsCannotClear(blocker) {
205
+ return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
206
+ blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA || blocker.startsWith("Merge conflicts");
207
+ }
208
+ function isTempestFindingsBlocker(attention) {
209
+ const blocker = attention.blocker.trim();
210
+ return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
211
+ /^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
212
+ }
159
213
  export function stackRepair(stack, attention, layer, bounce) {
160
214
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
161
215
  return null;
162
- if (stack.unit && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch)
163
- return null;
216
+ const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
164
217
  const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
165
218
  const mergeConflict = attention.blocker.startsWith("Merge conflicts");
166
219
  if (restackConflict || mergeConflict) {
167
- const liveParent = conflictLiveParent(stack, layer);
220
+ const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
168
221
  return {
169
222
  kind: restackConflict ? "restack_conflict" : "merge_conflict",
170
223
  prNumber: layer.prNumber,
@@ -175,6 +228,27 @@ export function stackRepair(stack, attention, layer, bounce) {
175
228
  steps: conflictRepairSteps(layer.branch, liveParent),
176
229
  };
177
230
  }
231
+ if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
232
+ return {
233
+ kind: "auto_land_off",
234
+ prNumber: layer.prNumber,
235
+ headSha: layer.headSha ?? null,
236
+ branch: layer.branch,
237
+ bounceKind: attention.bounceKind,
238
+ steps: `The ${attention.bounceKind} bounce turned Auto land off, so nothing will enqueue this stack. Fix the cause of the bounce on ${layer.branch}, then turn Auto land back on (mg stack set ${stack.id} --auto-land on) or enqueue the stack.`,
239
+ };
240
+ }
241
+ if (landPr && isTempestFindingsBlocker(attention)) {
242
+ const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
243
+ return {
244
+ kind: "tempest_findings",
245
+ prNumber: layer.prNumber,
246
+ headSha: layer.headSha ?? null,
247
+ branch: layer.branch,
248
+ findings,
249
+ steps: `Tempest never patches, and Cyclone is not patching this land PR. Read the Tempest report on it (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch on ${layer.branch}, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Do not retarget the PR base.`,
250
+ };
251
+ }
178
252
  if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
179
253
  const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
180
254
  return {
@@ -206,15 +280,31 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
206
280
  const issues = [];
207
281
  for (const layer of open) {
208
282
  const selected = layer.prNumber === candidate?.prNumber;
209
- const result = layerAttention(stack, layer, selected ? queue : [], cursor);
210
- if (!result.blocker)
283
+ let result = layerAttention(stack, layer, selected ? queue : [], cursor);
284
+ let disarmed;
285
+ if (!result.blocker && selected) {
286
+ disarmed = disarmingBounce(stack, queue);
287
+ if (disarmed) {
288
+ const kind = disarmed.bounceDetail.kind;
289
+ result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
290
+ }
291
+ }
292
+ if (!result.blocker) {
293
+ if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
294
+ issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
295
+ blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
296
+ }
211
297
  continue;
298
+ }
212
299
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
213
300
  blocker: result.blocker, bounceKind: result.bounceKind };
214
301
  if (selected || (!attention && layer === child)) {
215
302
  attention = issue;
216
303
  attentionLayer = layer;
217
304
  bounce = result.bounce;
305
+ if (disarmed)
306
+ issues.push({ prNumber: disarmed.bounceDetail?.prNumber ?? layer.prNumber,
307
+ headSha: disarmed.bounceDetail?.headSha ?? null, blocker: mergeQueueBounceLabel(disarmed), bounceKind: result.bounceKind });
218
308
  }
219
309
  else if (!candidate || layer.position > candidate.position)
220
310
  issues.push(issue);
@@ -235,19 +325,28 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
235
325
  const landGatePending = landLayer && landing && landGateIsPending(landing)
236
326
  ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
237
327
  : null;
328
+ if (busy.length && attention && agentsCannotClear(attention.blocker)) {
329
+ const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
330
+ return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
331
+ repair: stackRepair(stack, attention, attentionLayer, undefined), landGatePending };
332
+ }
238
333
  if (busy.length)
239
- return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
240
- return { attention, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
334
+ return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
335
+ return { attention, held: null, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
241
336
  }
242
337
  /** Compact contract text shared by status and wait summaries. */
243
- export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null) {
244
- const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
245
- const working = busy.length
246
- ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map((entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews").join(" and ")}`
338
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null) {
339
+ const agentWork = (entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews";
340
+ const blocked = attention && actAfter && busy.length
341
+ ? ` · blocked after the agents: #${attention.prNumber} ${attention.blocker} (plan the fix; act once ${busy.map(agentWork).join(" and ")} finish and the watch returns attention)`
342
+ : attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
343
+ const working = busy.length && !actAfter
344
+ ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map(agentWork).join(" and ")}`
247
345
  : "";
248
- const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
346
+ const listed = actAfter && attention ? issues.filter((issue) => issue.prNumber !== attention.prNumber || issue.blocker !== attention.blocker) : issues;
347
+ const labels = listed.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
249
348
  const gate = landGatePending && !attention
250
349
  ? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
251
350
  : "";
252
- return blocked + working + gate + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
351
+ return blocked + working + gate + (listed.length ? ` · issues: ${labels}${listed.length > 3 ? `; +${listed.length - 3} more` : ""}` : "");
253
352
  }
@@ -27,6 +27,8 @@ export declare function restackRetryPending(error: {
27
27
  * `api/src/stacks.ts#loadStackDtos` is the producer. Keep this file
28
28
  * dependency-free and byte-for-byte aligned with the canonical wire contract.
29
29
  */
30
+ export declare const AUTO_LAND_OFF_REASONS: readonly ["user", "archive", "watcher", "cancelled", "landed", "bounced"];
31
+ export type AutoLandOffReason = (typeof AUTO_LAND_OFF_REASONS)[number];
30
32
  export type StackBranchState = "clean" | "needs_restack" | "restacking" | "conflict" | "merged" | "closed";
31
33
  export type StackCiStatus = "success" | "pending" | "failure" | "neutral" | "none" | "unknown";
32
34
  export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" | "failed" | "none" | "unknown";
@@ -118,6 +120,7 @@ export type StackLayerChecks = {
118
120
  pending: number;
119
121
  failure: number;
120
122
  failingName: string | null;
123
+ selfHosted?: number;
121
124
  /**
122
125
  * True when the persisted `ci_checks` carries a `namedRuns` seed (enrich
123
126
  * writes it so CI webhook deliveries fold into the snapshot). Pre-seeding
@@ -158,6 +161,14 @@ export type StackLayerConflictResolution = {
158
161
  */
159
162
  dispatchId: string | null;
160
163
  };
164
+ export type StackLayerCiFixStatus = "verified" | "unverified" | "no_changes" | "failed";
165
+ export type StackLayerCiFix = {
166
+ status: StackLayerCiFixStatus;
167
+ failingCheck: string | null;
168
+ resultSha: string | null;
169
+ reason: string | null;
170
+ at: string;
171
+ };
161
172
  export type StackLayerDto = {
162
173
  branch: string;
163
174
  parentBranch: string | null;
@@ -232,6 +243,7 @@ export type StackLayerDto = {
232
243
  * layers and when the specialist never ran.
233
244
  */
234
245
  conflictResolution?: StackLayerConflictResolution | null;
246
+ ciFix?: StackLayerCiFix | null;
235
247
  };
236
248
  export type StackUnitMemberDto = {
237
249
  prNumber: number;
@@ -306,6 +318,11 @@ export type StackDto = {
306
318
  * Missing on older payloads is off.
307
319
  */
308
320
  autoEnqueueWhenReady?: boolean;
321
+ autoLandOff?: {
322
+ reason: AutoLandOffReason;
323
+ entryId: string | null;
324
+ at: string;
325
+ } | null;
309
326
  /**
310
327
  * Auto land Settling certificate. The stacks watcher publishes the identity
311
328
  * of the settle clock it actually started (action, exact PR and head, server
@@ -374,6 +391,7 @@ export type MergeQueueBounceDetail = {
374
391
  conflictDetail?: string;
375
392
  conflictPaths?: string[];
376
393
  message?: string;
394
+ warning?: string;
377
395
  };
378
396
  export type MergeQueueEntryDto = {
379
397
  id: string;
package/dist/stack-dto.js CHANGED
@@ -16,6 +16,13 @@ export function restackRetryPending(error, layer) {
16
16
  return false;
17
17
  return !/\(origin\/\S+ moved to /.test(error.detail) && pushRejectionIsRetryable(error.detail);
18
18
  }
19
+ /**
20
+ * Wire types returned by the stack list/enrich HTTP endpoints.
21
+ *
22
+ * `api/src/stacks.ts#loadStackDtos` is the producer. Keep this file
23
+ * dependency-free and byte-for-byte aligned with the canonical wire contract.
24
+ */
25
+ export const AUTO_LAND_OFF_REASONS = ["user", "archive", "watcher", "cancelled", "landed", "bounced"];
19
26
  export const STACK_AGENT_QUEUED_STALE_MS = 15 * 60 * 1000;
20
27
  function agentShaMayBeHead(sha, head) {
21
28
  const left = sha?.trim().toLowerCase();
@@ -1,4 +1,4 @@
1
- import { type StackAgentBusy, type StackBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
2
2
  import { type StackWatchObligation } from "./stack-watch-obligation.js";
3
3
  import { apiFetch } from "./api.js";
4
4
  import type { Config } from "./config.js";
@@ -28,6 +28,8 @@ export type StackWatchEnvelope = {
28
28
  } | null;
29
29
  assessment: "available" | "unavailable";
30
30
  busy: StackAgentBusy[];
31
+ actAfter: StackHeldBlocker["actAfter"] | null;
32
+ waitingOn: StackBusyAgent[];
31
33
  agents: StackLayerAgents | null;
32
34
  repair: StackRepairHint | null;
33
35
  landGatePending: StackLandGatePending | null;
@@ -48,7 +48,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
48
48
  let lastEnvelope = {
49
49
  schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
50
50
  bounceKind: null, prNumber: null, headSha: null, cursor,
51
- issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null, repair: null,
51
+ issues: [], currentCandidate: null, assessment: "unavailable", busy: [], actAfter: null, waitingOn: [], agents: null, repair: null,
52
52
  landGatePending: null,
53
53
  };
54
54
  let terminal = null;
@@ -119,7 +119,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
119
119
  : undefined;
120
120
  if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
121
121
  terminal = "not_found";
122
- throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [],
122
+ throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [], actAfter: null, waitingOn: [],
123
123
  prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
124
124
  }
125
125
  if (!stack || !Array.isArray(stack.layers)) {
@@ -140,15 +140,17 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
140
140
  };
141
141
  };
142
142
  const evaluate = (stack, entries) => {
143
- const { attention, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
143
+ const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
144
144
  if (bounce)
145
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
146
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
147
147
  const held = busy[0];
148
+ const shown = attention ?? named;
148
149
  lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
149
- blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
150
- prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
151
- headSha: attention ? attention.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
150
+ actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
151
+ blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
152
+ prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
153
+ headSha: shown ? shown.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
152
154
  status: attention ? "attention" : busy.length || landGatePending || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
153
155
  ? "in_progress" : "waiting" };
154
156
  };
package/dist/ui/banner.js CHANGED
@@ -111,6 +111,12 @@ const WHATS_NEW = {
111
111
  "0.3.26": [
112
112
  "stack results say the watch ends only at land",
113
113
  ],
114
+ "0.3.27": [
115
+ "mg dismiss records an audited finding dismissal",
116
+ ],
117
+ "0.3.28": [
118
+ "stack watch reads the Auto land off reason",
119
+ ],
114
120
  };
115
121
  function densityFromVariant(variant) {
116
122
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
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",
@@ -1,31 +1,49 @@
1
1
  ---
2
2
  name: mergestorm-bounce-watch
3
- description: Watch stacks explicitly named by the human while Auto land is on, and patch the current blocker after a bounce. Use for an ongoing stack watch, without taking over Cyclone or landing the stack.
3
+ description: Watch stacks explicitly named by the human while Auto land is on, patch the current blocker after a bounce, and turn Auto land back on once a bounce that disarmed it is handled. Use for an ongoing stack watch, without taking over Cyclone or landing the stack.
4
4
  ---
5
5
 
6
6
  # Mergestorm bounce watch
7
7
 
8
- Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
8
+ Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
9
9
 
10
10
  ## Not done until landed
11
11
 
12
- A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop.
12
+ A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack, turns Auto land off, or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop. A layer PR shown as merged is merged into `mg-stack-N`, not main: the stack is landed only when the land PR (head `mg-stack-N`) merges into main or `watch.done` says landed. Never judge landing from GitHub PR state alone.
13
13
 
14
14
  ## Scope and enrollment
15
15
 
16
16
  Watch only stacks the human named. If no unambiguous stack ID is available, ask the human to identify it; do not discover and enroll other stacks. Keep a separate cursor for each named stack.
17
17
 
18
- Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady` is `true` (Auto land on). If Auto land is off, missing, or cannot be verified, stop and tell the human. Never enable it yourself. Recheck this condition between wait slices and before patching or pushing. If Auto land is off when you recheck, call `queue_status` with the same `stack_id` and report the newest bounce (kind, PR, head, `finishedAt`) before stopping: `merge_failed`, `gh_error`, and `ci_timeout` can disarm Auto land. Stop when the stack is archived or its PRs are all merged or closed.
18
+ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady` is `true` (Auto land on). If Auto land is off, missing, or cannot be verified at enrollment, stop and tell the human, unless the human asked you in this task to turn it on: then call `stack_set` with `stack_id` and `auto_land: true` once and confirm `autoEnqueueWhenReady` is `true` with `stack_status`. Recheck this condition between wait slices and before patching or pushing. If Auto land is off when you recheck, read `autoLandOff` from `stack_status`: `reason` `bounced` means a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned it off, and `entryId` names that queue entry; call `queue_status` with the same `stack_id` for its kind, PR, head, and `finishedAt`. If "Re-arm after a bounce" covers it, keep watching, handle it, and then re-arm; otherwise report `autoLandOff` and the newest bounce and stop. Stop when the stack is archived or its PRs are all merged or closed.
19
19
 
20
20
  ## Watch and patch
21
21
 
22
22
  1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument. `watch.next.args` already holds them in that shape, with `timeout_s: 45`; passing it unchanged is the same call.
23
23
  2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, call `stack_wait` once more with the same cursor; if it fails again, stop and report the reason and that the stack is not landed. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
24
- 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy. A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. While `busy[]` holds a PR it can hide a blocker no agent will clear, such as a Draft PR. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off, and do not turn Auto land off or on, to end the wait. If the remote head moves while you wait, stop and tell the human.
25
- 4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
26
- 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`.
27
- 6. Recheck Auto land and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
28
- 7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
24
+ 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy; the only policy change you make is the re-arm in "Re-arm after a bounce". A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review or Tempest run, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
25
+ 4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer. Tempest never patches: Cyclone patches a land PR when auto-patch is on for that stack, otherwise you do. On `tempest_findings` on the land PR, when `busy[]` shows no Cyclone run on it, read the Tempest report, verify each finding against the checkout as for Vortex findings, fix the real ones with the smallest patch on `mg-stack-N`, and push with an ordinary push, never a force push; while Cyclone is patching it the envelope stays `in_progress` with `busy[]`, so wait. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
26
+ 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
27
+ 6. Recheck Auto land (off is expected only after a disarming bounce you are handling) and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
28
+ 7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue anything. If the bounce you handled turned Auto land off, turn it back on as "Re-arm after a bounce" allows; if that section does not allow it, report and stop.
29
+
30
+ ## Re-arm after a bounce
31
+
32
+ Turning Auto land back on is how you hand a handled stack back to the queue. Turning Auto land on, here or at enrollment when the human asks, is the only stack policy change you make.
33
+
34
+ Re-arm only when all of these hold:
35
+
36
+ - Auto land was on when you enrolled the stack, or the human asked you to turn it on.
37
+ - Auto land is off now, and `stack_status` shows `autoLandOff.reason` `bounced` with a non-null `autoLandOff.entryId`. That entry is the bounce you are handling: its `id` in `queue_status` is the `bounce_id`.
38
+ - You have not already re-armed for that `bounce_id`, and you have re-armed fewer than 3 times for this stack in this watch.
39
+ - You handled that bounce: you pushed a patch for its blocker, or its cause was infra only (a timeout, a flake, a runner fault) and you confirmed the cause is fixed and every required check is green at the current head of the bounced PR (`bounceDetail.prNumber`); rerun a failed or timed-out run there with `gh run rerun <run-id> --failed` if one exists. A timeout alone still does not justify a code change.
40
+ - Nothing needs a human: no "Decision required" seam finding, policy fork, `Seam review failed`, `Seam review pending, no review running`, `Review skipped, out of review quota`, null `liveParent`, or head moved by another actor.
41
+
42
+ Do not re-arm when `autoLandOff` is null or missing or its `reason` is anything but `bounced` (`user` means the human turned Auto land off; `archive`, `watcher`, `cancelled`, and `landed` are not yours to undo), when the stack was never armed, when the stack is archived, or when any condition above fails; report the newest bounce and stop.
43
+
44
+ After a patch, re-arm as soon as your push is confirmed; do not wait for CI or reviews, since Auto land itself waits for green checks and idle agents. First recheck the remote head of the PR you pushed, or of the bounced PR for an infra-only cause (`git ls-remote origin refs/heads/<branch>`), and `stack_status`: if that head is not the SHA you pushed or confirmed green, stop and tell the human; if Auto land is already on, leave it. Then call `stack_set` with `stack_id` and `auto_land: true` (the CLI equivalent is `mg stack set <id> --auto-land on`), and confirm with `stack_status` that `autoEnqueueWhenReady` is `true`. Pass no other `stack_set` key. Keep the `after_finished_at` and `bounce_id` selectors for that bounce and keep watching until the stack lands.
45
+
46
+ The cap is one re-arm per distinct `bounce_id` and 3 per stack per watch. Every arm clears `autoLandOff`, so a disarm after your re-arm reads its own reason, never your old bounce. A human who takes the stack back while it is off (turns Auto land off, archives or unarchives it, or cancels its queue entry) replaces `bounced` with `user`, `archive`, or `cancelled`, so `bounced` also means nobody took it back since that bounce. A fourth disarming bounce after three re-arms means your diagnosis is wrong or the fault is outside your scope. At the cap, leave Auto land off and stop: tell the human the stack is not landed, Auto land is off, and list each bounce you re-armed for (id, kind, PR, head, `finishedAt`) with what you did for it, plus the newest bounce and the current head and check state.
29
47
 
30
48
  ## Bounce kinds
31
49
 
@@ -42,7 +60,7 @@ The kind list is exactly `MERGE_QUEUE_BOUNCE_KINDS` from stack-dto:
42
60
  - `merge_failed`
43
61
  - `gh_error`
44
62
 
45
- A kind is context, not permission to mutate queue or stack policy. Diagnose the current blocker rather than blindly retrying its old bounce. A head-moved bounce requires stopping if another actor moved the PR head; a timeout alone does not justify a code change. If there is no concrete patch within this scope, report the blocker and stop.
63
+ A kind is context, not permission to mutate queue or stack policy beyond the re-arm in "Re-arm after a bounce". Diagnose the current blocker rather than blindly retrying its old bounce. A head-moved bounce requires stopping if another actor moved the PR head; a timeout alone does not justify a code change. If there is no concrete patch within this scope, report the blocker and stop.
46
64
 
47
65
  ## Never
48
66
 
@@ -50,7 +68,8 @@ A kind is context, not permission to mutate queue or stack policy. Diagnose the
50
68
  - Do not call `settings_set`, change account auto-patch settings, or turn account auto-patch off.
51
69
  - Do not set `auto_patch: true` on a stack.
52
70
  - Do not replace Cyclone or change stack auto-patch policy to take over its work.
53
- - Do not auto re-arm Auto land or a bounced queue entry.
71
+ - Do not turn Auto land on except at enrollment on the human's request or as "Re-arm after a bounce" allows, never turn it off, and never re-queue a bounced entry yourself.
72
+ - Do not call `stack_set` with any key but `stack_id` and `auto_land: true`: never `auto_land: false`, `auto_review`, or `auto_patch`.
54
73
  - Do not force-push except the same-live-parent rebase with the explicit starting-SHA lease above. Never rebase and retry after the remote head moved.
55
74
  - Do not change any PR's GitHub base (`gh pr edit --base` or the REST equivalent). Never move a bottom from `mg-stack-N` back to main or a parked PR off `mg-park-*`.
56
75
  - Do not push any branch other than the named blocker's PR head (or the one selected live CI issue after the gate is clean).
@@ -48,6 +48,11 @@ mg skill install --claude --cursor --agents
48
48
  - Prefer the smallest correct patch. Do not refactor around a finding.
49
49
  - Patch concrete bugs. A chat-only explanation is not a dismiss.
50
50
  - If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why). That first line is the only dismiss marker Vortex reads.
51
+ - If you verified a finding is wrong (not merely skipped) and it blocks the PR, for example a seam finding holding `Seam findings`, record the dismissal with `review_dismiss` (CLI: `mg dismiss`):
52
+ - First call it with `preview: true`, the PR's live `head_sha` and the review id (review_id) of the Vortex review that raised the finding. It lists that review's finding ids and the seam gate and writes nothing.
53
+ - Then call it with the `finding_ids` you checked (or `scope: "review"` when you checked every finding of that review), a `reason` that says why each is wrong, and an `evidence_url` when you have one.
54
+ - A refusal (stale_head, review_mismatch, unknown_finding, forbidden) means nothing was written. Re-read the pass at the live head; do not retry with another review's ids.
55
+ - The seam gate clears only when every finding of that seam review is dismissed. The result's `gate` says whether it did; CI, other reviews and Auto land are unchanged. For a stack PR, follow `watch.next` (the next `stack_wait` call).
51
56
  6. Patch on the PR head branch only.
52
57
  7. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
53
58
  8. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. If the last envelope you acted on has that same `head_sha` (no new commit, for example a re-review requested on the same head), also pass `after_pass` set to that envelope's saved `pass`, so the wait cannot hand you that attempt again. For a freshly pushed SHA omit `after_pass`; its first pass is 1. Keep `after_sha` and `after_pass` unchanged across 45s timeout retries; never replace `after_pass` with the `pass` of an in-progress envelope. `pass` reads one exact attempt; `timeout_s` 0 reads once without waiting. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
@@ -79,7 +84,8 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
79
84
  - patch a pass whose `head_sha` is not the commit you have checked out
80
85
  - wrap the CLI in a shell escape hatch
81
86
  - claim a finding is fixed without a diff that touches its path
82
- - claim a finding is dismissed without posting a `mergestorm-loop: dismiss` comment on the PR
87
+ - claim a finding is dismissed without posting a `mergestorm-loop: dismiss` comment on the PR or a `review_dismiss` result that lists it
88
+ - dismiss a finding you did not verify, one a human must decide (Decision required, policy fork), or a finding of a newer review than the one you checked
83
89
 
84
90
  ## Fallback (no MCP)
85
91