mergestorm 0.3.24 → 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.24 │
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.24: mg settings --auto-land-settle (15-300 seconds) │
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
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -5,6 +5,7 @@ export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandE
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
- export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
- export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead, type StackBlocker } from "../stack-blockers.js";
8
+ export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
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";
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";
@@ -5,6 +5,7 @@ export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandE
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
- export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
- export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
- export { stackBlockers, stackBlockersSummary, sameHead } from "../stack-blockers.js";
8
+ export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
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";
11
+ export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
@@ -144,8 +144,12 @@ async function removeQueue(args, asJson) {
144
144
  console.log(JSON.stringify(body, null, 2));
145
145
  return;
146
146
  }
147
+ const cancelRequested = typeof body === "object" && body !== null &&
148
+ body.cancelRequested === true;
147
149
  await present("Merge queue", [
148
- ansi.brightGreen(` Removed ${entryId} from the merge queue`),
150
+ cancelRequested
151
+ ? ansi.brightGreen(` Cancel requested for ${entryId}: it is merging, and the queue cancels it before the merge call unless the merge already started`)
152
+ : ansi.brightGreen(` Removed ${entryId} from the merge queue`),
149
153
  ]);
150
154
  }
151
155
  export async function cmdQueue(args, deps = {}) {
@@ -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.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
  });
@@ -1,4 +1,4 @@
1
- import { type MergeQueueEntryDto, type StackDto, type StackLayerDto } from "./stack-dto.js";
1
+ import { type MergeQueueEntryDto, type StackAgentsBusy, type StackDto, type StackLayerDto } from "./stack-dto.js";
2
2
  import type { StackWatchCursor } from "./stack-watch.js";
3
3
  export type StackBlocker = {
4
4
  prNumber: number;
@@ -6,11 +6,59 @@ export type StackBlocker = {
6
6
  blocker: string;
7
7
  bounceKind: string | null;
8
8
  };
9
+ export type StackAgentBusy = {
10
+ prNumber: number;
11
+ headSha: string | null;
12
+ agent: "vortex" | "cyclone";
13
+ blocker: string;
14
+ };
15
+ export type StackLayerAgents = {
16
+ prNumber: number;
17
+ headSha: string | null;
18
+ vortexStatus: StackLayerDto["vortexStatus"];
19
+ cycloneStatus: StackLayerDto["cycloneStatus"];
20
+ vortexReview: {
21
+ status: string;
22
+ headSha: string | null;
23
+ pass: number | null;
24
+ } | null;
25
+ busy: {
26
+ vortex: boolean;
27
+ cyclone: boolean;
28
+ };
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;
52
+ export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
53
+ export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
9
54
  export declare function isMgParkBase(branch: string | null | undefined): boolean;
10
55
  /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
11
56
  export declare function boundDirty(layer: StackLayerDto): boolean;
12
57
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
13
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;
14
62
  /** Pair-gate attention plus live hard blocks on every other open layer. */
15
63
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
16
64
  attention: StackBlocker | null;
@@ -20,6 +68,10 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
20
68
  headSha: string | null;
21
69
  } | null;
22
70
  bounce: MergeQueueEntryDto | undefined;
71
+ busy: StackAgentBusy[];
72
+ agents: StackLayerAgents | null;
73
+ repair: StackRepairHint | null;
74
+ landGatePending: StackLandGatePending | null;
23
75
  };
24
76
  /** Compact contract text shared by status and wait summaries. */
25
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[]): 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,23 @@
1
- import { mergeQueueBounceLabel, restackRetryPending } from "./stack-dto.js";
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
+ }
6
+ export function layerAgentsBusy(layer) {
7
+ return layer.agentsBusy ?? deriveStackAgentsBusy(layer);
8
+ }
9
+ export function layerAgents(layer) {
10
+ const busy = layerAgentsBusy(layer);
11
+ const review = layer.vortexReview;
12
+ return {
13
+ prNumber: layer.prNumber,
14
+ headSha: layer.headSha ?? null,
15
+ vortexStatus: layer.vortexStatus ?? null,
16
+ cycloneStatus: layer.cycloneStatus ?? null,
17
+ vortexReview: review ? { status: review.status, headSha: review.head_sha, pass: review.pass } : null,
18
+ busy: { vortex: busy.vortex, cyclone: busy.cyclone },
19
+ };
20
+ }
2
21
  export function isMgParkBase(branch) {
3
22
  return (branch ?? "").trim().toLowerCase().startsWith("mg-park-");
4
23
  }
@@ -26,6 +45,22 @@ export function currentLayer(stack) {
26
45
  return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
27
46
  (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
28
47
  }
48
+ function seamGateBlocker(stack, layer) {
49
+ const members = stack.unit?.members ?? [];
50
+ if (layer.prNumber === stack.unit?.landPrNumber)
51
+ return null;
52
+ if (!members.some((member) => member.promotedHeadSha?.trim()))
53
+ return null;
54
+ const member = members.find((entry) => entry.prNumber === layer.prNumber);
55
+ if (!member || member.promotedHeadSha?.trim())
56
+ return null;
57
+ const seamState = member.seamState?.trim().toLowerCase();
58
+ if (seamState === "failed")
59
+ return "Seam review failed";
60
+ if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
61
+ return "Seam findings";
62
+ return null;
63
+ }
29
64
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
30
65
  function layerAttention(stack, layer, queue, cursor) {
31
66
  const none = { blocker: null, bounceKind: null };
@@ -63,9 +98,13 @@ function layerAttention(stack, layer, queue, cursor) {
63
98
  return hardBlock(`Tempest ${tempest.status}`);
64
99
  if (layer.vortexStatus === "failed")
65
100
  return hardBlock("Review failed");
101
+ const seam = seamGateBlocker(stack, layer);
102
+ if (seam)
103
+ return hardBlock(seam);
66
104
  if (stack.unit?.landPrNumber === layer.prNumber) {
67
- if (stack.unit.landingBlockReason?.trim())
68
- return hardBlock(stack.unit.landingBlockReason.trim());
105
+ const landing = stack.unit.landingBlockReason?.trim();
106
+ if (landing && !landGateIsPending(landing))
107
+ return hardBlock(landing);
69
108
  if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
70
109
  return hardBlock("Tempest failed");
71
110
  }
@@ -81,6 +120,74 @@ function layerAttention(stack, layer, queue, cursor) {
81
120
  return none;
82
121
  return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
83
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
+ }
84
191
  /** Pair-gate attention plus live hard blocks on every other open layer. */
85
192
  export function stackBlockers(stack, entries = [], cursor = {}) {
86
193
  const candidate = currentLayer(stack);
@@ -94,6 +201,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
94
201
  (boundDirty(layer) || layer.state === "conflict" ||
95
202
  (layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
96
203
  let attention = null;
204
+ let attentionLayer;
97
205
  let bounce;
98
206
  const issues = [];
99
207
  for (const layer of open) {
@@ -105,16 +213,41 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
105
213
  blocker: result.blocker, bounceKind: result.bounceKind };
106
214
  if (selected || (!attention && layer === child)) {
107
215
  attention = issue;
216
+ attentionLayer = layer;
108
217
  bounce = result.bounce;
109
218
  }
110
219
  else if (!candidate || layer.position > candidate.position)
111
220
  issues.push(issue);
112
221
  }
113
- return { attention, issues, currentCandidate, bounce };
222
+ const focus = attentionLayer ?? candidate;
223
+ const agents = focus ? layerAgents(focus) : null;
224
+ const busy = [];
225
+ if (attention && agents) {
226
+ for (const agent of ["cyclone", "vortex"]) {
227
+ if (agents.busy[agent])
228
+ busy.push({ prNumber: attention.prNumber, headSha: attention.headSha, agent, blocker: attention.blocker });
229
+ }
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;
238
+ 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 };
114
241
  }
115
242
  /** Compact contract text shared by status and wait summaries. */
116
- export function stackBlockersSummary(attention, issues) {
243
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null) {
117
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 ")}`
247
+ : "";
118
248
  const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
119
- return blocked + (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` : ""}` : "");
120
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;
@@ -98,6 +102,16 @@ export type StackAgentRun = {
98
102
  total: number;
99
103
  } | null;
100
104
  };
105
+ export type StackAgentsBusy = {
106
+ vortex: boolean;
107
+ cyclone: boolean;
108
+ headSha: string | null;
109
+ };
110
+ export declare const STACK_AGENT_QUEUED_STALE_MS: number;
111
+ export declare function deriveStackAgentsBusy(layer: Pick<StackLayerDto, "headSha" | "vortexStatus" | "cycloneStatus" | "agentRuns" | "vortexReview">, opts?: {
112
+ cycloneClaimed?: boolean;
113
+ nowMs?: number;
114
+ }): StackAgentsBusy;
101
115
  export type StackLayerChecks = {
102
116
  total: number;
103
117
  success: number;
@@ -182,6 +196,7 @@ export type StackLayerDto = {
182
196
  * detail once it says `done`. Undefined = producer predates the viewer.
183
197
  */
184
198
  vortexReview?: StackVortexReviewView;
199
+ agentsBusy?: StackAgentsBusy | null;
185
200
  conflictDetail: string | null;
186
201
  restackError?: RestackError | null;
187
202
  lastRestackedSha: string | null;
@@ -357,6 +372,7 @@ export type MergeQueueBounceDetail = {
357
372
  failingCheck?: string;
358
373
  conflictBranch?: string;
359
374
  conflictDetail?: string;
375
+ conflictPaths?: string[];
360
376
  message?: string;
361
377
  };
362
378
  export type MergeQueueEntryDto = {
@@ -379,5 +395,6 @@ export type MergeQueueEntryDto = {
379
395
  verifyHeadSha: string | null;
380
396
  /** Target tip included in the verified PR head. Null until recorded. */
381
397
  verifyBaseSha: string | null;
398
+ cancelRequestedAt?: string | null;
382
399
  finishedAt: string | null;
383
400
  };
package/dist/stack-dto.js CHANGED
@@ -16,6 +16,33 @@ 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
+ export const STACK_AGENT_QUEUED_STALE_MS = 15 * 60 * 1000;
20
+ function agentShaMayBeHead(sha, head) {
21
+ const left = sha?.trim().toLowerCase();
22
+ const right = head?.trim().toLowerCase();
23
+ if (!left || !right)
24
+ return false;
25
+ return left.startsWith(right) || right.startsWith(left);
26
+ }
27
+ export function deriveStackAgentsBusy(layer, opts = {}) {
28
+ const headSha = layer.headSha?.trim() || null;
29
+ const nowMs = opts.nowMs ?? Date.now();
30
+ const runs = (layer.agentRuns ?? []).filter((run) => !run.finishedAt);
31
+ const vortexRuns = runs.filter((run) => run.agent === "vortex" && agentShaMayBeHead(run.sha, headSha));
32
+ const freshQueued = vortexRuns.some((run) => {
33
+ const queuedAt = Date.parse(run.startedAt ?? "");
34
+ return run.status === "queued" && Number.isFinite(queuedAt) && nowMs - queuedAt <= STACK_AGENT_QUEUED_STALE_MS;
35
+ });
36
+ const review = layer.vortexReview;
37
+ const reviewOnHead = !!review && agentShaMayBeHead(review.head_sha, headSha);
38
+ const vortex = (reviewOnHead && review.status === "reviewing") ||
39
+ vortexRuns.some((run) => run.status === "reviewing") ||
40
+ freshQueued ||
41
+ (layer.agentRuns === undefined && review === undefined && layer.vortexStatus === "reviewing");
42
+ const cyclone = opts.cycloneClaimed === true || layer.cycloneStatus === "patching" ||
43
+ runs.some((run) => run.agent === "cyclone" && run.status === "patching");
44
+ return { vortex, cyclone, headSha };
45
+ }
19
46
  /** Every bounce kind understood by the shared contract. */
20
47
  export const MERGE_QUEUE_BOUNCE_KINDS = [
21
48
  "ci_failure",
@@ -47,6 +74,7 @@ export function mergeQueueBounceLabel(entry) {
47
74
  if (kindLabel) {
48
75
  const specifics = detail?.failingCheck ??
49
76
  detail?.conflictBranch ??
77
+ (detail?.conflictPaths?.length ? detail.conflictPaths.join(", ") : null) ??
50
78
  detail?.message ??
51
79
  (detail?.prNumber != null ? `PR #${detail.prNumber}` : null);
52
80
  return specifics ? `${kindLabel} — ${specifics}` : kindLabel;