mergestorm 0.3.25 → 0.3.26

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.25 │
49
- │ ▜▙ ● msk_live_… · maelstrom │
50
- │ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
51
- │ │
52
- │ review a diff · usage for tabs · /help for all commands │
53
- │ New in v0.3.25: stack wait holds a PR while Cyclone or Vortex works │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.26 │
49
+ │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
+ │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
+ │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
52
+ │ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
53
+ │ ▀ New: stack results say the watch ends only at land │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -7,4 +7,5 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFal
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, type StackAgentBusy, type StackBlocker, type StackLayerAgents, } from "../stack-blockers.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, type StackAgentBusy, type StackBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
11
+ export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
@@ -7,4 +7,5 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-enve
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, } from "../stack-blockers.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, } from "../stack-blockers.js";
11
+ export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
@@ -224,7 +224,7 @@ export const COMMAND_REGISTRY = [
224
224
  "mergestorm stack reset --force Clear this repo's pre-submit authoring state",
225
225
  "mergestorm stack list [--json] List registered stacks",
226
226
  "mergestorm stack status <stack-id> [--json] Enriched stack status",
227
- "mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)",
227
+ "mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait). --json: one document on stdout; timeout notice on stderr, exit 5",
228
228
  "mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json] Per-stack policy",
229
229
  "mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] Import an open PR chain",
230
230
  "mergestorm stack restack <stack-id> Restack stack descendants",
@@ -1,3 +1,5 @@
1
+ import { type StackRepairHint } from "../stack-blockers.js";
2
+ import { type StackWatchObligation } from "../stack-watch-obligation.js";
1
3
  import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
2
4
  import { loadConfig } from "../config.js";
3
5
  import { createPrs, findOpenPrNumber } from "../gh.js";
@@ -7,6 +9,11 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
7
9
  import { gitTopLevel } from "../repo-identity.js";
8
10
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
9
11
  import { pollStackWatch } from "../stack-watch.js";
12
+ export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
13
+ export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
14
+ export declare function openStackWatch(stackId: string, opts?: {
15
+ unread?: boolean;
16
+ }): StackWatchObligation;
10
17
  /** A registered stack layer that would become the parent of a new layer. */
11
18
  export type RegisteredParentHit = {
12
19
  stackId: string;
@@ -140,6 +147,7 @@ export type StackSetDeps = {
140
147
  };
141
148
  /** Human summary lines for a `stack set` write, one per flag. */
142
149
  export declare function stackSetSummary(parsed: StackSetArgs): string[];
150
+ export declare function withWatch(body: unknown, watch: StackWatchObligation | null): unknown;
143
151
  export declare function cmdStackSet(args: string[], deps?: StackSetDeps): Promise<void>;
144
152
  export declare function cmdStack(args: string[], options?: {
145
153
  signal?: AbortSignal;
@@ -1,4 +1,5 @@
1
- import { stackBlockersSummary } from "../stack-blockers.js";
1
+ import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
2
+ import { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
2
3
  import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
3
4
  import { loadConfig } from "../config.js";
4
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
@@ -15,6 +16,26 @@ import { runLineTabsBrowser } from "../ui/line-tabs.js";
15
16
  import { present } from "../ui/present.js";
16
17
  import { canBrowse } from "./browse.js";
17
18
  import { openHelpBrowser } from "./help.js";
19
+ export function stackRepairLine(repair) {
20
+ if (!repair)
21
+ return [];
22
+ const files = "files" in repair && repair.files.length ? ` (files: ${repair.files.join(", ")})` : "";
23
+ return [`Repair #${repair.prNumber} ${repair.kind}${files}: ${repair.steps}`];
24
+ }
25
+ export function stackWatchTextLines(watch) {
26
+ if (!watch)
27
+ return [];
28
+ if (watch.done)
29
+ return [watch.message];
30
+ const next = watch.next;
31
+ return [
32
+ ...(next ? [`Next: ${next.command} (MCP: stack_wait ${JSON.stringify(next.args)})`] : []),
33
+ STACK_WATCH_NOT_DONE_SENTENCE,
34
+ ];
35
+ }
36
+ export function openStackWatch(stackId, opts = {}) {
37
+ return stackWatchObligation({ stackId, terminal: null, unread: opts.unread, freshCursor: true });
38
+ }
18
39
  const STACK_USAGE = `usage:
19
40
  mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
20
41
  mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--rest] [--json]
@@ -22,6 +43,8 @@ const STACK_USAGE = `usage:
22
43
  mergestorm stack list [--json]
23
44
  mergestorm stack status <stack-id> [--json]
24
45
  mergestorm stack wait <stack-id> [--json] [--timeout <s>]
46
+ --json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
47
+ (exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
25
48
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
26
49
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
27
50
  mergestorm stack restack <stack-id> [--json]
@@ -780,6 +803,7 @@ export async function cmdStackSubmit(args, deps = {}) {
780
803
  stackId: stackId ?? null,
781
804
  trunk: data.stack?.trunkBranch ?? meta.trunk,
782
805
  chain: data.chain ?? null,
806
+ watch: stackId ? openStackWatch(stackId) : null,
783
807
  }, null, 2));
784
808
  return;
785
809
  }
@@ -799,6 +823,8 @@ export async function cmdStackSubmit(args, deps = {}) {
799
823
  summary.push(ansi.dim(" Stack registration returned no stack ID"));
800
824
  }
801
825
  summary.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
826
+ if (stackId)
827
+ summary.push(...stackWatchTextLines(openStackWatch(stackId)).map((line) => ` ${line}`));
802
828
  await present("Stack", summary);
803
829
  }
804
830
  async function cmdStackReset(args) {
@@ -868,7 +894,11 @@ export async function cmdStackWait(args, deps = {}) {
868
894
  ...envelope,
869
895
  ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
870
896
  }, null, 2)
871
- : `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`);
897
+ : [
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" : ""}`,
899
+ ...stackRepairLine(envelope.repair),
900
+ ...stackWatchTextLines(envelope.watch),
901
+ ].join("\n"));
872
902
  try {
873
903
  print(await (deps.pollStackWatch ?? pollStackWatch)(cfg, stackId, {
874
904
  timeoutMs: timeoutS * 1000,
@@ -892,6 +922,7 @@ export async function cmdStackWait(args, deps = {}) {
892
922
  }
893
923
  export async function cmdStackStatus(args, deps = {}) {
894
924
  const usage = "usage: mergestorm stack status <stack-id> [--json]";
925
+ const asJson = args.includes("--json");
895
926
  const positional = args.filter((arg) => arg !== "--json");
896
927
  if (positional.length !== 1)
897
928
  throw new CommandError(usage, 2, "usage");
@@ -899,10 +930,25 @@ export async function cmdStackStatus(args, deps = {}) {
899
930
  const cfg = await (deps.loadConfig ?? loadConfig)();
900
931
  const stack = await (deps.getEnrichedStack ?? getEnrichedStack)(stackId, cfg);
901
932
  if (!stack) {
933
+ const watch = stackWatchObligation({ stackId, terminal: "not_found" });
934
+ if (asJson) {
935
+ console.log(JSON.stringify({ error: { code: "not_found", stack_id: stackId }, watch }, null, 2));
936
+ process.exitCode = 1;
937
+ return;
938
+ }
939
+ console.log(watch.message);
902
940
  throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
903
941
  }
904
- if (args.includes("--json")) {
905
- console.log(JSON.stringify(stack, null, 2));
942
+ const { attention, currentCandidate, repair } = stackBlockers(stack);
943
+ const watch = stackWatchObligation({
944
+ stackId: stack.id,
945
+ terminal: stackTerminalReason(stack),
946
+ cursor: { enrolledHeadSha: currentCandidate?.headSha ?? null },
947
+ attention: attention ? { prNumber: attention.prNumber, blocker: attention.blocker } : null,
948
+ freshCursor: true,
949
+ });
950
+ if (asJson) {
951
+ console.log(JSON.stringify({ ...stack, repair, watch }, null, 2));
906
952
  return;
907
953
  }
908
954
  const lines = formatStackHuman(stack, false);
@@ -915,6 +961,7 @@ export async function cmdStackStatus(args, deps = {}) {
915
961
  (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
916
962
  (layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
917
963
  }
964
+ lines.push(...[...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
918
965
  await present("Stack status", lines);
919
966
  }
920
967
  /** Human summary lines for a `stack set` write, one per flag. */
@@ -932,6 +979,11 @@ export function stackSetSummary(parsed) {
932
979
  }
933
980
  return lines;
934
981
  }
982
+ export function withWatch(body, watch) {
983
+ if (!watch || typeof body !== "object" || body === null || Array.isArray(body))
984
+ return body;
985
+ return { ...body, watch };
986
+ }
935
987
  export async function cmdStackSet(args, deps = {}) {
936
988
  const parsed = parseStackSetArgs(args);
937
989
  const cfg = await (deps.loadConfig ?? loadConfig)();
@@ -940,18 +992,26 @@ export async function cmdStackSet(args, deps = {}) {
940
992
  ...(parsed.autoReview !== undefined ? { autoReviewOverride: parsed.autoReview } : {}),
941
993
  ...(parsed.autoPatch !== undefined ? { autoPatchOverride: parsed.autoPatch } : {}),
942
994
  }, cfg);
995
+ const watch = openStackWatch(parsed.stackId, { unread: true });
943
996
  if (parsed.asJson) {
944
- console.log(JSON.stringify(body, null, 2));
997
+ console.log(JSON.stringify(withWatch(body, watch), null, 2));
945
998
  return;
946
999
  }
947
- await present("Stack", stackSetSummary(parsed).map((line) => ansi.brightGreen(` ${line} for ${parsed.stackId}`)));
1000
+ await present("Stack", [
1001
+ ...stackSetSummary(parsed).map((line) => ansi.brightGreen(` ${line} for ${parsed.stackId}`)),
1002
+ ...stackWatchTextLines(watch).map((line) => ` ${line}`),
1003
+ ]);
948
1004
  }
949
1005
  async function cmdStackAdopt(args) {
950
1006
  const { owner, repo, prNumber, autoLand, autoReview, autoPatch, asJson } = parseStackAdoptArgs(args);
951
1007
  const cfg = await loadConfig();
952
1008
  const body = await adoptStack(owner, repo, prNumber, cfg, openPolicyPatch({ autoLand, autoReview, autoPatch }));
1009
+ const adoptedId = typeof body === "object" && body !== null
1010
+ ? body.stack?.id
1011
+ : undefined;
1012
+ const watch = typeof adoptedId === "string" && adoptedId ? openStackWatch(adoptedId, { unread: true }) : null;
953
1013
  if (asJson) {
954
- console.log(JSON.stringify(body, null, 2));
1014
+ console.log(JSON.stringify(withWatch(body, watch), null, 2));
955
1015
  return;
956
1016
  }
957
1017
  if (typeof body !== "object" || body === null) {
@@ -969,6 +1029,7 @@ async function cmdStackAdopt(args) {
969
1029
  imported.push(ansi.dim(` trunk: ${stack.trunkBranch}`));
970
1030
  }
971
1031
  imported.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
1032
+ imported.push(...stackWatchTextLines(watch).map((line) => ` ${line}`));
972
1033
  await present("Stack", imported);
973
1034
  }
974
1035
  async function cmdStackRestack(args) {
package/dist/shell.js CHANGED
@@ -79,7 +79,7 @@ export async function runShell() {
79
79
  commands: COMMANDS,
80
80
  ...(banner
81
81
  ? {
82
- header: (columns, variant) => banner.rows(columns, variant),
82
+ header: (columns, variant, frame) => banner.rows(columns, variant, frame),
83
83
  }
84
84
  : {}),
85
85
  });
@@ -27,6 +27,28 @@ export type StackLayerAgents = {
27
27
  cyclone: boolean;
28
28
  };
29
29
  };
30
+ export type StackRepairHint = {
31
+ kind: "restack_conflict" | "merge_conflict";
32
+ prNumber: number;
33
+ headSha: string | null;
34
+ branch: string;
35
+ liveParent: string | null;
36
+ files: string[];
37
+ steps: string;
38
+ } | {
39
+ kind: "ci_failure";
40
+ prNumber: number;
41
+ headSha: string | null;
42
+ branch: string;
43
+ failingCheck: string | null;
44
+ steps: string;
45
+ };
46
+ export type StackLandGatePending = {
47
+ prNumber: number;
48
+ headSha: string | null;
49
+ reason: string;
50
+ };
51
+ export declare function landGateIsPending(reason: string | null | undefined): boolean;
30
52
  export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
31
53
  export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
32
54
  export declare function isMgParkBase(branch: string | null | undefined): boolean;
@@ -34,6 +56,9 @@ export declare function isMgParkBase(branch: string | null | undefined): boolean
34
56
  export declare function boundDirty(layer: StackLayerDto): boolean;
35
57
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
36
58
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
59
+ export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
60
+ export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
61
+ export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
37
62
  /** Pair-gate attention plus live hard blocks on every other open layer. */
38
63
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
39
64
  attention: StackBlocker | null;
@@ -45,6 +70,8 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
45
70
  bounce: MergeQueueEntryDto | undefined;
46
71
  busy: StackAgentBusy[];
47
72
  agents: StackLayerAgents | null;
73
+ repair: StackRepairHint | null;
74
+ landGatePending: StackLandGatePending | null;
48
75
  };
49
76
  /** Compact contract text shared by status and wait summaries. */
50
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[]): string;
77
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null): string;
@@ -1,4 +1,8 @@
1
1
  import { deriveStackAgentsBusy, mergeQueueBounceLabel, restackRetryPending, } from "./stack-dto.js";
2
+ export function landGateIsPending(reason) {
3
+ const token = reason?.trim().replace(/^landing blocked:\s*/i, "").match(/^[a-z_]+/i)?.[0] ?? "";
4
+ return /_(?:pending|running)$/i.test(token);
5
+ }
2
6
  export function layerAgentsBusy(layer) {
3
7
  return layer.agentsBusy ?? deriveStackAgentsBusy(layer);
4
8
  }
@@ -98,8 +102,9 @@ function layerAttention(stack, layer, queue, cursor) {
98
102
  if (seam)
99
103
  return hardBlock(seam);
100
104
  if (stack.unit?.landPrNumber === layer.prNumber) {
101
- if (stack.unit.landingBlockReason?.trim())
102
- return hardBlock(stack.unit.landingBlockReason.trim());
105
+ const landing = stack.unit.landingBlockReason?.trim();
106
+ if (landing && !landGateIsPending(landing))
107
+ return hardBlock(landing);
103
108
  if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
104
109
  return hardBlock("Tempest failed");
105
110
  }
@@ -115,6 +120,74 @@ function layerAttention(stack, layer, queue, cursor) {
115
120
  return none;
116
121
  return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
117
122
  }
123
+ function isTerminalLayer(layer) {
124
+ return layer.state === "merged" || layer.state === "closed";
125
+ }
126
+ export function conflictLiveParent(stack, layer) {
127
+ const promoted = (candidate) => !!stack.unit?.members?.some((member) => member.prNumber === candidate.prNumber && member.promotedHeadSha?.trim());
128
+ const sibling = stack.layers
129
+ .filter((candidate) => candidate.prNumber > 0 && candidate.position < layer.position &&
130
+ !isTerminalLayer(candidate) && !promoted(candidate))
131
+ .sort((a, b) => b.position - a.position)[0];
132
+ if (sibling)
133
+ return sibling.branch;
134
+ const retired = new Set([
135
+ ...stack.layers.filter((candidate) => isTerminalLayer(candidate) || promoted(candidate)).map((candidate) => candidate.branch),
136
+ ...(stack.unit?.members ?? []).filter((member) => member.promotedHeadSha?.trim()).map((member) => member.branch),
137
+ ]);
138
+ const usable = (branch) => !!branch?.trim() && !isMgParkBase(branch) && !retired.has(branch) && branch !== layer.branch;
139
+ for (const branch of [layer.restackError?.from?.branch, layer.parentBranch, stack.unit?.branch, stack.trunkBranch]) {
140
+ if (usable(branch))
141
+ return branch;
142
+ }
143
+ return null;
144
+ }
145
+ function conflictFiles(layer, bounce) {
146
+ const paths = bounce?.bounceDetail?.conflictPaths?.filter((path) => path.trim());
147
+ if (paths?.length)
148
+ return [...paths];
149
+ const detail = layer.restackError?.kind === "rebase_conflict" ? layer.restackError.detail : layer.conflictDetail;
150
+ const listed = detail?.match(/^conflict in (.+)$/)?.[1];
151
+ return listed ? listed.split(", ").map((path) => path.trim()).filter(Boolean) : [];
152
+ }
153
+ export function conflictRepairSteps(branch, liveParent) {
154
+ if (!liveParent) {
155
+ return `The live parent of ${branch} is unknown (it is never an mg-park-* freeze). Do not merge or rebase anything; stop and tell the human.`;
156
+ }
157
+ 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
+ }
159
+ export function stackRepair(stack, attention, layer, bounce) {
160
+ if (!attention || !layer || attention.prNumber !== layer.prNumber)
161
+ return null;
162
+ if (stack.unit && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch)
163
+ return null;
164
+ const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
165
+ const mergeConflict = attention.blocker.startsWith("Merge conflicts");
166
+ if (restackConflict || mergeConflict) {
167
+ const liveParent = conflictLiveParent(stack, layer);
168
+ return {
169
+ kind: restackConflict ? "restack_conflict" : "merge_conflict",
170
+ prNumber: layer.prNumber,
171
+ headSha: layer.headSha ?? null,
172
+ branch: layer.branch,
173
+ liveParent,
174
+ files: conflictFiles(layer, bounce),
175
+ steps: conflictRepairSteps(layer.branch, liveParent),
176
+ };
177
+ }
178
+ if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
179
+ const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
180
+ return {
181
+ kind: "ci_failure",
182
+ prNumber: layer.prNumber,
183
+ headSha: layer.headSha ?? null,
184
+ branch: layer.branch,
185
+ failingCheck,
186
+ steps: `Read the failing ${failingCheck ? `check ${failingCheck}` : "checks"} on ${layer.branch} (gh run list --branch ${layer.branch}, then gh run view <run-id> --log-failed), fix it on ${layer.branch} with the smallest patch, run that check locally, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push. Do not retarget the PR base.`,
187
+ };
188
+ }
189
+ return null;
190
+ }
118
191
  /** Pair-gate attention plus live hard blocks on every other open layer. */
119
192
  export function stackBlockers(stack, entries = [], cursor = {}) {
120
193
  const candidate = currentLayer(stack);
@@ -155,16 +228,26 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
155
228
  busy.push({ prNumber: attention.prNumber, headSha: attention.headSha, agent, blocker: attention.blocker });
156
229
  }
157
230
  }
231
+ const landing = stack.unit?.landingBlockReason?.trim();
232
+ const landLayer = stack.unit?.landPrNumber != null && !stack.archivedAt
233
+ ? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber)
234
+ : undefined;
235
+ const landGatePending = landLayer && landing && landGateIsPending(landing)
236
+ ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
237
+ : null;
158
238
  if (busy.length)
159
- return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents };
160
- return { attention, issues, currentCandidate, bounce, busy, agents };
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 };
161
241
  }
162
242
  /** Compact contract text shared by status and wait summaries. */
163
- export function stackBlockersSummary(attention, issues, busy = []) {
243
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null) {
164
244
  const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
165
245
  const working = busy.length
166
246
  ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map((entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews").join(" and ")}`
167
247
  : "";
168
248
  const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
169
- return blocked + working + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
249
+ const gate = landGatePending && !attention
250
+ ? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
251
+ : "";
252
+ return blocked + working + gate + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
170
253
  }
@@ -5,6 +5,10 @@ export type RestackError = {
5
5
  attemptedAt: string;
6
6
  attempts: number;
7
7
  backupRef: string | null;
8
+ from?: {
9
+ branch: string;
10
+ oldSha: string;
11
+ };
8
12
  };
9
13
  export declare function restackAttemptsExhausted(error: Pick<RestackError, "attempts">): boolean;
10
14
  export declare function pushRejectionIsRetryable(message: string): boolean;
@@ -0,0 +1,43 @@
1
+ import type { StackDto } from "./stack-dto.js";
2
+ export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
3
+ export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
4
+ export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
5
+ export type StackWatchNextArgs = {
6
+ stack_id: string;
7
+ enrolled_head_sha?: string | null;
8
+ after_finished_at?: string | null;
9
+ bounce_id?: string | null;
10
+ timeout_s: 45;
11
+ };
12
+ export type StackWatchNext = {
13
+ tool: "stack_wait";
14
+ args: StackWatchNextArgs;
15
+ command: string;
16
+ };
17
+ export type StackWatchObligation = {
18
+ done: boolean;
19
+ until: "landed";
20
+ reason: StackWatchReason;
21
+ next: StackWatchNext | null;
22
+ message: string;
23
+ };
24
+ export type StackWatchObligationInput = {
25
+ stackId: string;
26
+ terminal: StackWatchDoneReason | null;
27
+ cursor?: {
28
+ enrolledHeadSha?: string | null;
29
+ afterFinishedAt?: string | null;
30
+ bounceId?: string | null;
31
+ } | null;
32
+ status?: string;
33
+ attention?: {
34
+ prNumber: number;
35
+ blocker: string;
36
+ } | null;
37
+ retryAfterSeconds?: number;
38
+ unread?: boolean;
39
+ freshCursor?: boolean;
40
+ };
41
+ export declare function stackTerminalReason(stack: Pick<StackDto, "archivedAt" | "layers" | "unit"> | null | undefined): StackWatchDoneReason | null;
42
+ export declare function stackWatchNextArgs(stackId: string, cursor: StackWatchObligationInput["cursor"]): StackWatchNextArgs;
43
+ export declare function stackWatchObligation(input: StackWatchObligationInput): StackWatchObligation;
@@ -0,0 +1,79 @@
1
+ export const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
2
+ const DONE_MESSAGES = {
3
+ landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
4
+ closed: "Every PR in this stack is merged or closed and nothing is left to land, but not every PR landed. The watch is done; tell the human which PRs were closed without landing.",
5
+ archived: "This stack is archived: the human took it back. The watch is done; stop calling stack_wait for it and do not push to its PRs.",
6
+ not_found: "No stack with this id exists for this account. Mergestorm deletes a stack once none of its layers is open, so a stack you were watching has most likely landed; a mistyped id or another account's stack reads the same. The watch is done; confirm each PR is merged (gh pr view <n> --json state) before reporting it landed.",
7
+ };
8
+ export function stackTerminalReason(stack) {
9
+ if (!stack)
10
+ return "not_found";
11
+ if (stack.archivedAt)
12
+ return "archived";
13
+ const layers = stack.layers;
14
+ if (layers.some((layer) => layer.state !== "merged" && layer.state !== "closed"))
15
+ return null;
16
+ const unit = stack.unit;
17
+ if (layers.length === 0 && !unit)
18
+ return null;
19
+ const landPr = unit?.landPr;
20
+ if (landPr && landPr.prNumber > 0 && landPr.state !== "merged" && landPr.state !== "closed")
21
+ return null;
22
+ if (unit && unit.state !== "landed" && unit.state !== "abandoned")
23
+ return null;
24
+ const everyLayerMerged = layers.every((layer) => layer.state === "merged");
25
+ return everyLayerMerged && (!unit || unit.state === "landed") ? "landed" : "closed";
26
+ }
27
+ export function stackWatchNextArgs(stackId, cursor) {
28
+ return {
29
+ stack_id: stackId,
30
+ ...(cursor && cursor.enrolledHeadSha !== undefined ? { enrolled_head_sha: cursor.enrolledHeadSha } : {}),
31
+ ...(cursor && cursor.afterFinishedAt !== undefined ? { after_finished_at: cursor.afterFinishedAt } : {}),
32
+ ...(cursor && cursor.bounceId !== undefined ? { bounce_id: cursor.bounceId } : {}),
33
+ timeout_s: 45,
34
+ };
35
+ }
36
+ export function stackWatchObligation(input) {
37
+ if (input.terminal) {
38
+ return {
39
+ done: true,
40
+ until: "landed",
41
+ reason: input.terminal,
42
+ next: null,
43
+ message: DONE_MESSAGES[input.terminal],
44
+ };
45
+ }
46
+ const args = stackWatchNextArgs(input.stackId, input.cursor);
47
+ const reason = input.status === "rate_limited"
48
+ ? "rate_limited"
49
+ : input.status === "failed"
50
+ ? "failed"
51
+ : input.attention ? "attention" : input.unread ? "unread" : "open";
52
+ const parts = [
53
+ STACK_WATCH_NOT_DONE_SENTENCE,
54
+ `Watch stack ${input.stackId} as a whole until it lands or the human takes it back. A clean push, a submit, or one merged layer does not finish it: merging a lower layer can break a layer above it.`,
55
+ ];
56
+ if (reason === "rate_limited") {
57
+ parts.push(`Wait ${input.retryAfterSeconds !== undefined ? `${input.retryAfterSeconds}s` : "retry_after_seconds"} first, then call stack_wait with the same cursor.`);
58
+ }
59
+ else if (reason === "failed") {
60
+ parts.push(`This read failed, which does not end the task. Call stack_wait once more with the same cursor; if that fails too, tell the human that stack ${input.stackId} is not landed and is no longer being watched.`);
61
+ }
62
+ else if (reason === "attention" && input.attention) {
63
+ parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed.`);
64
+ }
65
+ else if (reason === "unread") {
66
+ parts.push("This tool did not read the stack's state; stack_wait will.");
67
+ }
68
+ parts.push(`Next call: stack_wait ${JSON.stringify(args)}.`);
69
+ if (input.freshCursor) {
70
+ parts.push("If you already hold a cursor from an earlier stack_wait on this stack, keep passing that cursor instead.");
71
+ }
72
+ return {
73
+ done: false,
74
+ until: "landed",
75
+ reason,
76
+ next: { tool: "stack_wait", args, command: `mg stack wait ${input.stackId} --json` },
77
+ message: parts.join(" "),
78
+ };
79
+ }
@@ -1,4 +1,5 @@
1
- import { type StackAgentBusy, type StackBlocker, type StackLayerAgents } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
2
+ import { type StackWatchObligation } from "./stack-watch-obligation.js";
2
3
  import { apiFetch } from "./api.js";
3
4
  import type { Config } from "./config.js";
4
5
  export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
@@ -28,6 +29,9 @@ export type StackWatchEnvelope = {
28
29
  assessment: "available" | "unavailable";
29
30
  busy: StackAgentBusy[];
30
31
  agents: StackLayerAgents | null;
32
+ repair: StackRepairHint | null;
33
+ landGatePending: StackLandGatePending | null;
34
+ watch: StackWatchObligation;
31
35
  };
32
36
  export type PollStackWatchOptions = {
33
37
  timeoutMs?: number;
@@ -1,4 +1,5 @@
1
1
  import { currentLayer, layerAgents, stackBlockers, } from "./stack-blockers.js";
2
+ import { stackTerminalReason, stackWatchObligation, } from "./stack-watch-obligation.js";
2
3
  import { setTimeout as sleep } from "node:timers/promises";
3
4
  import { apiFetch } from "./api.js";
4
5
  import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
@@ -47,10 +48,20 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
47
48
  let lastEnvelope = {
48
49
  schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
49
50
  bounceKind: null, prNumber: null, headSha: null, cursor,
50
- issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null,
51
+ issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null, repair: null,
52
+ landGatePending: null,
51
53
  };
54
+ let terminal = null;
55
+ const seal = (snapshot, retryAfterSeconds) => ({
56
+ ...snapshot,
57
+ watch: stackWatchObligation({
58
+ stackId: id, terminal, cursor: snapshot.cursor, status: snapshot.status, retryAfterSeconds,
59
+ attention: snapshot.status === "attention" && snapshot.blocker && snapshot.prNumber != null
60
+ ? { prNumber: snapshot.prNumber, blocker: snapshot.blocker } : null,
61
+ }),
62
+ });
52
63
  let transientFailures = 0;
53
- const timeout = () => new StackWatchTimeoutError(lastEnvelope);
64
+ const timeout = () => new StackWatchTimeoutError(seal(lastEnvelope));
54
65
  const pause = async (ms) => {
55
66
  const remaining = Math.min(ms, Math.max(0, deadline - now()));
56
67
  if (remaining > 0)
@@ -76,11 +87,11 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
76
87
  throw timeout();
77
88
  if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
78
89
  transientFailures += 1;
79
- opts.onTick?.(lastEnvelope);
90
+ opts.onTick?.(seal(lastEnvelope));
80
91
  await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
81
92
  continue;
82
93
  }
83
- throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed", assessment: "unavailable" }, { cause: err });
94
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }), { cause: err });
84
95
  }
85
96
  if (response.status === 200)
86
97
  return response.body;
@@ -90,14 +101,14 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
90
101
  if (timeoutMs !== 0 && isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES &&
91
102
  (response.status !== 429 || retryWaitMs < deadline - now())) {
92
103
  transientFailures += 1;
93
- opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
104
+ opts.onTick?.(seal({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status }, response.status === 429 ? response.retryAfterSeconds : undefined));
94
105
  await pause(retryWaitMs);
95
106
  continue;
96
107
  }
97
- throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
108
+ throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, seal({
98
109
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
99
110
  assessment: "unavailable",
100
- }, undefined, response.retryAfterSeconds);
111
+ }, response.retryAfterSeconds), undefined, response.retryAfterSeconds);
101
112
  }
102
113
  throw timeout();
103
114
  };
@@ -106,16 +117,22 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
106
117
  const stack = snapshot && Array.isArray(snapshot.stacks)
107
118
  ? snapshot.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
108
119
  : undefined;
120
+ if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
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: [],
123
+ prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
124
+ }
109
125
  if (!stack || !Array.isArray(stack.layers)) {
110
- throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
126
+ throw new StackWatchError("Stack snapshot missing or invalid", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
111
127
  }
128
+ terminal = stackTerminalReason(stack);
112
129
  return stack;
113
130
  };
114
131
  const queueRoute = `/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`;
115
132
  const readQueue = async (route, finalSnapshot = false) => {
116
133
  const queue = await request(route, finalSnapshot);
117
134
  if (!queue || !Array.isArray(queue.entries)) {
118
- throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
135
+ throw new StackWatchError("Invalid merge queue response", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
119
136
  }
120
137
  return {
121
138
  entries: queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id),
@@ -123,16 +140,16 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
123
140
  };
124
141
  };
125
142
  const evaluate = (stack, entries) => {
126
- const { attention, issues, currentCandidate, bounce, busy, agents } = stackBlockers(stack, entries, cursor);
143
+ const { attention, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
127
144
  if (bounce)
128
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
129
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
130
147
  const held = busy[0];
131
- lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents,
148
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
132
149
  blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
133
150
  prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
134
151
  headSha: attention ? attention.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
135
- status: attention ? "attention" : busy.length || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
152
+ status: attention ? "attention" : busy.length || landGatePending || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
136
153
  ? "in_progress" : "waiting" };
137
154
  };
138
155
  let stack = await enrich(timeoutMs === 0);
@@ -145,8 +162,8 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
145
162
  agents: layer ? layerAgents(layer) : null };
146
163
  let queue = await readQueue(queueRoute, timeoutMs === 0);
147
164
  evaluate(stack, queue.entries);
148
- if (lastEnvelope.status === "attention" || timeoutMs === 0)
149
- return lastEnvelope;
165
+ if (lastEnvelope.status === "attention" || timeoutMs === 0 || terminal)
166
+ return seal(lastEnvelope);
150
167
  while (now() < deadline) {
151
168
  const heldAt = now();
152
169
  const seconds = Math.min(lastEnvelope.busy.length ? STACK_WATCH_BUSY_RECHECK_S : 45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
@@ -162,9 +179,9 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
162
179
  agents: refreshed ? layerAgents(refreshed) : null };
163
180
  transientFailures = 0;
164
181
  evaluate(stack, queue.entries);
165
- if (lastEnvelope.status === "attention")
166
- return lastEnvelope;
167
- opts.onTick?.(lastEnvelope);
182
+ if (lastEnvelope.status === "attention" || terminal)
183
+ return seal(lastEnvelope);
184
+ opts.onTick?.(seal(lastEnvelope));
168
185
  }
169
186
  throw timeout();
170
187
  }
package/dist/ui/ansi.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
3
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
4
+ */
5
+ export declare const BRAND_GREEN: {
6
+ readonly hex: "#5FAF5F";
7
+ readonly index: 77;
8
+ };
9
+ export declare const BRAND_GREEN_SGR: string;
10
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
11
+ export declare const BRAND_FILL_SGR: string;
1
12
  export declare const ansi: {
2
13
  readonly enabled: boolean;
3
14
  green: (t: string) => string;
@@ -7,8 +18,10 @@ export declare const ansi: {
7
18
  yellow: (t: string) => string;
8
19
  gray: (t: string) => string;
9
20
  brightGreen: (t: string) => string;
21
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
22
+ brandFill: (t: string) => string;
10
23
  invert: (t: string) => string;
11
24
  boldGreen: (t: string) => string;
12
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
25
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
13
26
  boldBrightGreen: (t: string) => string;
14
27
  };
package/dist/ui/ansi.js CHANGED
@@ -9,7 +9,25 @@ function computeEnabled() {
9
9
  function wrap(code, text) {
10
10
  if (!computeEnabled())
11
11
  return text;
12
- return `[${code}m${text}`;
12
+ return `\x1b[${code}m${text}\x1b[0m`;
13
+ }
14
+ /**
15
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
16
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
17
+ */
18
+ export const BRAND_GREEN = { hex: "#5FAF5F", index: 77 };
19
+ export const BRAND_GREEN_SGR = `38;5;${BRAND_GREEN.index}`;
20
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
21
+ export const BRAND_FILL_SGR = `${BRAND_GREEN_SGR};48;5;${BRAND_GREEN.index}`;
22
+ function brandGreen(text, bold = false) {
23
+ if (!computeEnabled())
24
+ return text;
25
+ return `\x1b[${bold ? "1;" : ""}${BRAND_GREEN_SGR}m${text}\x1b[0m`;
26
+ }
27
+ function brandFill(text) {
28
+ if (!computeEnabled())
29
+ return text;
30
+ return `\x1b[${BRAND_FILL_SGR}m${text}\x1b[0m`;
13
31
  }
14
32
  export const ansi = {
15
33
  get enabled() {
@@ -21,9 +39,11 @@ export const ansi = {
21
39
  red: (t) => wrap("31", t),
22
40
  yellow: (t) => wrap("33", t),
23
41
  gray: (t) => wrap("90", t),
24
- brightGreen: (t) => wrap("92", t),
42
+ brightGreen: (t) => brandGreen(t),
43
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
44
+ brandFill: (t) => brandFill(t),
25
45
  invert: (t) => wrap("7", t),
26
- boldGreen: (t) => (computeEnabled() ? `${t}` : t),
27
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
28
- boldBrightGreen: (t) => (computeEnabled() ? `${t}` : t),
46
+ boldGreen: (t) => (computeEnabled() ? `\x1b[1;32m${t}\x1b[0m` : t),
47
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
48
+ boldBrightGreen: (t) => brandGreen(t, true),
29
49
  };
@@ -14,7 +14,7 @@ export type BannerDensity = "full" | "compact" | "mini" | "nano";
14
14
  export type BannerVariant = boolean | "mini" | "nano";
15
15
  /** Handle returned by {@link printBannerHeader}; `rows` reflows for a resize. */
16
16
  export type BannerHandle = {
17
- rows(columns?: number, variant?: BannerVariant): string[];
17
+ rows(columns?: number, variant?: BannerVariant, frame?: number): string[];
18
18
  };
19
19
  /**
20
20
  * The whole welcome panel as rows, reflowed to `columns`. Every row fits in
@@ -24,6 +24,7 @@ export type BannerHandle = {
24
24
  export declare function buildBannerRows(state: BannerState, columns?: number, opts?: {
25
25
  compact?: boolean;
26
26
  density?: BannerDensity;
27
+ frame?: number;
27
28
  }): string[];
28
29
  /**
29
30
  * Print the welcome panel and return a handle whose `rows()` reflows the
package/dist/ui/banner.js CHANGED
@@ -3,8 +3,8 @@ import { CommandError } from "../errors.js";
3
3
  import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { cliVersion } from "../version.js";
5
5
  import { ansi } from "./ansi.js";
6
- import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
7
- import { STORM_MARK, STORM_MARK_WIDTH } from "./logo.js";
6
+ import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, terminalColumns, visibleWidth, } from "./box.js";
7
+ import { paintSpriteRow, TORNADO_LOGO, TORNADO_LOGO_WIDTH } from "./logo.js";
8
8
  import { formatResetLabel, usageBar } from "./usage.js";
9
9
  const WORDMARK = `
10
10
  __ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
@@ -108,6 +108,9 @@ const WHATS_NEW = {
108
108
  "0.3.25": [
109
109
  "stack wait holds a PR while Cyclone or Vortex works",
110
110
  ],
111
+ "0.3.26": [
112
+ "stack results say the watch ends only at land",
113
+ ],
111
114
  };
112
115
  function densityFromVariant(variant) {
113
116
  if (variant === "mini" || variant === "nano")
@@ -199,57 +202,30 @@ export function buildBannerRows(state, columns, opts) {
199
202
  const contentCells = boxed
200
203
  ? Math.max(20, outer - 4)
201
204
  : Math.max(20, terminalColumns(80, columns) - 1);
202
- const indent = STORM_MARK_WIDTH + 2;
203
- const pad = " ".repeat(indent);
205
+ const markLines = TORNADO_LOGO;
206
+ const markWidth = TORNADO_LOGO_WIDTH;
207
+ const gap = 2;
208
+ const rightCells = Math.max(8, contentCells - markWidth - gap);
204
209
  const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
205
- const mark = (row) => padVisible(ansi.brightGreen(STORM_MARK[row] ?? ""), indent);
210
+ const clipRight = (line) => visibleWidth(line) > rightCells ? padVisible(line, rightCells) : line;
206
211
  const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
207
- if (density === "nano") {
208
- const one = `${mark(0)}${title} ${statusLine(state)}`;
209
- return [clip(one)];
210
- }
211
- if (density === "mini") {
212
- // Short pane: mark + who you are, then the same cues as the full home.
213
- const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
214
- const line1 = state.me && !state.keyInvalid
215
- ? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
216
- : clip(mark(1));
217
- const room = contentCells - indent;
218
- const blurb = tagline(room);
219
- const tips = tipsLine(room);
220
- const fresh = whatsNewLine(state.version, room);
221
- return [
222
- line0,
223
- line1,
224
- ...(blurb ? [clip(`${pad}${blurb}`)] : []),
225
- ...(tips ? [clip(`${pad}${tips}`)] : []),
226
- ...(fresh ? [clip(`${pad}${fresh}`)] : []),
227
- ];
228
- }
229
- const content = [
230
- clip(`${pad}${title}`),
231
- clip(`${mark(0)}${statusLine(state)}`),
232
- ];
212
+ const right = [clipRight(title), clipRight(statusLine(state))];
233
213
  if (state.me && !state.keyInvalid) {
234
- content.push(clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`));
214
+ right.push(clipRight(usageLine(state.me, rightCells, 0)));
235
215
  }
236
- else {
237
- content.push(clip(mark(1)));
238
- }
239
- const room = contentCells - indent;
240
- const blurb = tagline(room);
241
- const tips = tipsLine(room);
242
- const fresh = whatsNewLine(state.version, room);
216
+ const blurb = tagline(rightCells);
217
+ const tips = tipsLine(rightCells);
218
+ const fresh = whatsNewLine(state.version, rightCells);
243
219
  if (blurb)
244
- content.push(clip(`${pad}${blurb}`));
220
+ right.push(clipRight(blurb));
245
221
  if (tips)
246
- content.push(clip(`${pad}${tips}`));
222
+ right.push(clipRight(tips));
247
223
  if (fresh)
248
- content.push(clip(`${pad}${fresh}`));
249
- if (boxed && density === "full") {
250
- content.splice(3, 0, "");
251
- return [...roundedBox(content, { width: outer, padding: 1 })];
252
- }
224
+ right.push(clipRight(fresh));
225
+ const markCol = markLines.map((line) => padVisible(paintSpriteRow(line), markWidth));
226
+ const content = sideBySide(markCol, density === "nano" ? right.slice(0, 3) : right, markWidth, gap).map(clip);
227
+ if (density === "mini" || density === "nano")
228
+ return content;
253
229
  if (boxed) {
254
230
  return [...roundedBox(content, { width: outer, padding: 1 })];
255
231
  }
@@ -285,7 +261,7 @@ export async function printBannerHeader(opts) {
285
261
  }
286
262
  }
287
263
  return {
288
- rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
264
+ rows: (columns, variant, frame) => buildBannerRows(state, columns, { density: densityFromVariant(variant), frame }),
289
265
  };
290
266
  }
291
267
  export function shellPrompt(_loggedIn) {
package/dist/ui/logo.d.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
2
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
3
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
4
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
5
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
6
  */
5
- export declare const STORM_MARK: string[];
6
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
7
- export declare const STORM_MARK_WIDTH: number;
8
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
9
7
  export declare const TORNADO_LOGO: string[];
10
- /** @deprecated Alias. */
8
+ /** Single idle frame. Kept for callers that index frames. */
9
+ export declare const TORNADO_FRAMES: string[][];
10
+ /** Every sprite glyph is one column. */
11
11
  export declare const TORNADO_LOGO_WIDTH: number;
12
- /** @deprecated Compact home is the full two-row mark. */
13
12
  export declare const TORNADO_LOGO_COMPACT: string[];
13
+ /** @deprecated Use {@link TORNADO_LOGO}. */
14
+ export declare const STORM_MARK: string[];
15
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
16
+ export declare const STORM_MARK_WIDTH: number;
17
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
18
+ export declare function paintSpriteRow(row: string): string;
package/dist/ui/logo.js CHANGED
@@ -1,16 +1,28 @@
1
+ import { ansi } from "./ansi.js";
1
2
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
3
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
4
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
5
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
6
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
7
  */
5
- export const STORM_MARK = [
6
- "▟██▛",
7
- " ▜▙",
8
+ export const TORNADO_LOGO = [
9
+ " ▀▀▀▀▀▀▀▀▀▀▘",
10
+ "▀▀▀▀▀▀▀▀▀ ",
11
+ " ▀▀▀▀▀▀ ",
12
+ " ▝▀▀▀▀▀ ",
13
+ " ▝▀▀▘ ",
14
+ " ▀ ",
8
15
  ];
9
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
10
- export const STORM_MARK_WIDTH = Math.max(...STORM_MARK.map((line) => line.length));
11
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
12
- export const TORNADO_LOGO = STORM_MARK;
13
- /** @deprecated Alias. */
14
- export const TORNADO_LOGO_WIDTH = STORM_MARK_WIDTH;
15
- /** @deprecated Compact home is the full two-row mark. */
16
- export const TORNADO_LOGO_COMPACT = STORM_MARK;
16
+ /** Single idle frame. Kept for callers that index frames. */
17
+ export const TORNADO_FRAMES = [TORNADO_LOGO];
18
+ /** Every sprite glyph is one column. */
19
+ export const TORNADO_LOGO_WIDTH = Math.max(...TORNADO_LOGO.map((line) => [...line].length));
20
+ export const TORNADO_LOGO_COMPACT = TORNADO_LOGO;
21
+ /** @deprecated Use {@link TORNADO_LOGO}. */
22
+ export const STORM_MARK = TORNADO_LOGO;
23
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
24
+ export const STORM_MARK_WIDTH = TORNADO_LOGO_WIDTH;
25
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
26
+ export function paintSpriteRow(row) {
27
+ return row.replace(/[^ ]+/g, (run) => ansi.brightGreen(run));
28
+ }
@@ -23,7 +23,7 @@ export interface AskLineOptions {
23
23
  * resize cannot desync CUU counts. Pass `true` for compact, `"mini"` /
24
24
  * `"nano"` for a short pane that still shows the mark.
25
25
  */
26
- header?: (columns?: number, variant?: boolean | "mini" | "nano") => string[];
26
+ header?: (columns?: number, variant?: boolean | "mini" | "nano", frame?: number) => string[];
27
27
  }
28
28
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
29
29
  export declare class PromptClosedError extends Error {
@@ -47,6 +47,16 @@ export declare function promptClearOverlay(boxed: boolean): string;
47
47
  * frame's row count is unknowable after the terminal rewraps.
48
48
  */
49
49
  export declare const HEADER_REDRAW_PREFIX = "\u001B[H\u001B[0J";
50
+ export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
51
+ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
52
+ export type SpriteRect = {
53
+ col: number;
54
+ row: number;
55
+ width: number;
56
+ height: number;
57
+ };
58
+ /** SGR coordinates and the rectangle are both 1-based. */
59
+ export declare function hitTestSprite(col: number, row: number, rect: SpriteRect): boolean;
50
60
  export declare const DROPDOWN_MAX_COMMAND_ROWS = 8;
51
61
  export declare function formatSlashLabel(spec: CommandSpec): string;
52
62
  export declare function specMatchesTerm(spec: CommandSpec, term: string): boolean;
package/dist/ui/prompt.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { StringDecoder } from "node:string_decoder";
1
3
  import * as readline from "node:readline";
2
4
  import { createInterface } from "node:readline/promises";
3
5
  import { stdin, stdout } from "node:process";
@@ -37,6 +39,13 @@ export function promptClearOverlay(boxed) {
37
39
  * frame's row count is unknowable after the terminal rewraps.
38
40
  */
39
41
  export const HEADER_REDRAW_PREFIX = `\u001b[H${clearDown}`;
42
+ export const MOUSE_ENABLE = "\u001b[?1000h\u001b[?1006h";
43
+ export const MOUSE_DISABLE = "\u001b[?1000l\u001b[?1006l";
44
+ /** SGR coordinates and the rectangle are both 1-based. */
45
+ export function hitTestSprite(col, row, rect) {
46
+ return col >= rect.col && col < rect.col + rect.width &&
47
+ row >= rect.row && row < rect.row + rect.height;
48
+ }
40
49
  export const DROPDOWN_MAX_COMMAND_ROWS = 8;
41
50
  export function formatSlashLabel(spec) {
42
51
  const extra = spec.aliases?.length
@@ -171,9 +180,10 @@ export function fitHomeFrame(params) {
171
180
  : params.miniHeader?.length
172
181
  ? params.miniHeader
173
182
  : [];
183
+ const header = fallback.slice(0, Math.max(0, rows - 1));
174
184
  return {
175
- header: fallback,
176
- reserve: params.dockBottom ? Math.max(0, rows - fallback.length - 1) : 0,
185
+ header,
186
+ reserve: params.dockBottom ? Math.max(0, rows - header.length - 1) : 0,
177
187
  boxed: false,
178
188
  hasPrompt: false,
179
189
  hasHint: false,
@@ -338,7 +348,72 @@ export async function askLine(opts) {
338
348
  let exitHint = false;
339
349
  const ctrlCExit = new CtrlCExitGate();
340
350
  const wasRaw = Boolean(input.isRaw);
341
- readline.emitKeypressEvents(input);
351
+ // Decode keys from a private stream after removing mouse reports. Readline
352
+ // otherwise emits the numeric tail of SGR reports as ordinary typing.
353
+ // Never listen for `keypress` on `input` itself: once any TUI has called
354
+ // emitKeypressEvents(stdin), stdin decodes keys too and every character
355
+ // (and every mouse report) would land in the buffer twice.
356
+ const keys = new PassThrough();
357
+ const decoder = new StringDecoder("utf8");
358
+ let pending = "";
359
+ let escapeTimer;
360
+ readline.emitKeypressEvents(keys);
361
+ keys.on("keypress", onKeypress);
362
+ function onData(chunk) {
363
+ if (finished)
364
+ return;
365
+ if (escapeTimer)
366
+ clearTimeout(escapeTimer);
367
+ pending += typeof chunk === "string" ? chunk : decoder.write(chunk);
368
+ while (pending && !finished) {
369
+ const start = pending.indexOf("\u001b");
370
+ if (start !== 0) {
371
+ const end = start < 0 ? pending.length : start;
372
+ const text = pending.slice(0, end);
373
+ pending = pending.slice(end);
374
+ keys.write(text);
375
+ continue;
376
+ }
377
+ if (pending.startsWith("\u001b[M")) {
378
+ if (pending.length < 6)
379
+ return;
380
+ pending = pending.slice(6);
381
+ continue;
382
+ }
383
+ const mouse = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])/.exec(pending);
384
+ if (mouse) {
385
+ pending = pending.slice(mouse[0].length);
386
+ continue;
387
+ }
388
+ const partialMouse = /^\u001b\[<\d*(?:;\d*){0,2}/.exec(pending);
389
+ if (partialMouse && pending.length > partialMouse[0].length) {
390
+ pending = pending.slice(partialMouse[0].length);
391
+ continue;
392
+ }
393
+ const csi = /^\u001b\[[0-?]*[ -/]*[@-~]/.exec(pending);
394
+ const ss3 = /^\u001bO[ -~]/.exec(pending);
395
+ if (csi || ss3) {
396
+ const sequence = csi?.[0] ?? ss3[0];
397
+ pending = pending.slice(sequence.length);
398
+ keys.write(sequence);
399
+ continue;
400
+ }
401
+ if (pending === "\u001b" ||
402
+ /^\u001b(?:\[[0-?]*[ -/]*|O?)$/.test(pending)) {
403
+ // Keep partial mouse reports across chunks. A lone Escape still works.
404
+ if (!pending.startsWith("\u001b[<")) {
405
+ escapeTimer = setTimeout(() => {
406
+ const text = pending;
407
+ pending = "";
408
+ keys.write(text);
409
+ }, 50);
410
+ }
411
+ return;
412
+ }
413
+ keys.write(pending[0]);
414
+ pending = pending.slice(1);
415
+ }
416
+ }
342
417
  input.setRawMode(true);
343
418
  input.resume();
344
419
  function currentDropdown() {
@@ -408,9 +483,8 @@ export async function askLine(opts) {
408
483
  // Never write more rows than the pane — that scrolls the welcome
409
484
  // tail (tips / what's new) into view and looks like a broken frame.
410
485
  if (frame.rows.length > roomRows) {
411
- const keep = frame.rows.slice(frame.rows.length - roomRows);
412
- const shift = frame.rows.length - keep.length;
413
- frame.rows = keep;
486
+ const shift = frame.rows.length - roomRows;
487
+ frame.rows = frame.rows.slice(shift);
414
488
  frame.inputRow = Math.max(0, frame.inputRow - shift);
415
489
  }
416
490
  // Home screen owns the pane: always origin + ED 0. Relative CUU after
@@ -442,7 +516,11 @@ export async function askLine(opts) {
442
516
  paint();
443
517
  }
444
518
  function cleanup() {
445
- input.removeListener("keypress", onKeypress);
519
+ if (escapeTimer)
520
+ clearTimeout(escapeTimer);
521
+ input.removeListener("data", onData);
522
+ keys.removeListener("keypress", onKeypress);
523
+ keys.destroy();
446
524
  output.removeListener("resize", onResize);
447
525
  input.setRawMode(wasRaw);
448
526
  // emitKeypressEvents leaves stdin flowing; pause it so an idle shell
@@ -599,8 +677,13 @@ export async function askLine(opts) {
599
677
  finish(null, err);
600
678
  }
601
679
  }
602
- input.on("keypress", onKeypress);
680
+ input.on("data", onData);
603
681
  output.on("resize", onResize);
604
- render();
682
+ try {
683
+ render();
684
+ }
685
+ catch (err) {
686
+ finish(null, err);
687
+ }
605
688
  });
606
689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
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",
@@ -7,6 +7,10 @@ description: Watch stacks explicitly named by the human while Auto land is on, a
7
7
 
8
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.
9
9
 
10
+ ## Not done until landed
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.
13
+
10
14
  ## Scope and enrollment
11
15
 
12
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.
@@ -15,9 +19,9 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
15
19
 
16
20
  ## Watch and patch
17
21
 
18
- 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.
19
- 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, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
20
- 3. 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.
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
+ 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.
21
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-*`.
22
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[]`.
23
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.