mergestorm 0.3.18 → 0.3.19

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.
@@ -7,14 +7,16 @@ import { gitTopLevel } from "../repo-identity.js";
7
7
  import { present } from "../ui/present.js";
8
8
  export const SKILL_NAME = "mergestorm-review";
9
9
  export const PR_LOOP_SKILL_NAME = "mergestorm-pr-loop";
10
- export const SKILL_NAMES = [SKILL_NAME, PR_LOOP_SKILL_NAME];
10
+ export const BOUNCE_WATCH_SKILL_NAME = "mergestorm-bounce-watch";
11
+ export const SKILL_NAMES = [SKILL_NAME, PR_LOOP_SKILL_NAME, BOUNCE_WATCH_SKILL_NAME];
11
12
  export const SKILL_USAGE = `usage:
12
- mergestorm skill install --claude|--cursor [--json]
13
+ mergestorm skill install --claude|--cursor|--agents [--json]
13
14
 
14
- Copies mergestorm-review and mergestorm-pr-loop into this repo (git toplevel, else cwd):
15
+ Copies ${SKILL_NAMES.join(", ")} into this repo (git toplevel, else cwd):
15
16
  --claude .claude/skills/<name>/SKILL.md
16
17
  --cursor .cursor/skills/<name>/SKILL.md
17
- Pass both flags to write both trees. Overwrites existing copies.`;
18
+ --agents .agents/skills/<name>/SKILL.md
19
+ Pass multiple flags to write multiple trees. Overwrites existing copies.`;
18
20
  export function parseSkillArgs(args) {
19
21
  const [sub, ...rest] = args;
20
22
  if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
@@ -40,13 +42,18 @@ export function parseSkillArgs(args) {
40
42
  targets.push("cursor");
41
43
  continue;
42
44
  }
45
+ if (arg === "--agents") {
46
+ if (!targets.includes("agents"))
47
+ targets.push("agents");
48
+ continue;
49
+ }
43
50
  if (arg === "-h" || arg === "--help" || arg === "help") {
44
51
  return { help: true, json: false, targets: [] };
45
52
  }
46
53
  throw new CommandError(`${SKILL_USAGE}\nunknown flag: ${arg}`, 1, "usage");
47
54
  }
48
55
  if (targets.length === 0) {
49
- throw new CommandError(`${SKILL_USAGE}\npass --claude and/or --cursor`, 1, "usage");
56
+ throw new CommandError(`${SKILL_USAGE}\npass --claude, --cursor, and/or --agents`, 1, "usage");
50
57
  }
51
58
  return { help: false, json, targets };
52
59
  }
@@ -65,6 +72,9 @@ export function skillDestination(root, target, name = SKILL_NAME) {
65
72
  if (target === "claude") {
66
73
  return path.join(root, ".claude", "skills", name, "SKILL.md");
67
74
  }
75
+ if (target === "agents") {
76
+ return path.join(root, ".agents", "skills", name, "SKILL.md");
77
+ }
68
78
  return path.join(root, ".cursor", "skills", name, "SKILL.md");
69
79
  }
70
80
  export function resolveBundledSkillPath(fromFile = import.meta.url, name = SKILL_NAME) {
@@ -90,7 +100,7 @@ export function resolveBundledSkillPath(fromFile = import.meta.url, name = SKILL
90
100
  }
91
101
  async function assertNoSymlink(dest) {
92
102
  const parts = dest.split(path.sep);
93
- const marker = parts.findIndex((p) => p === ".claude" || p === ".cursor");
103
+ const marker = parts.findIndex((p) => p === ".claude" || p === ".cursor" || p === ".agents");
94
104
  const first = marker === -1 ? parts.length - 1 : marker;
95
105
  for (let i = first; i < parts.length; i++) {
96
106
  const candidate = parts.slice(0, i + 1).join(path.sep);
@@ -1,4 +1,4 @@
1
- import { adoptStack, ensureUpperPark, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, getEnrichedStack, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { createPr, findOpenPrNumber } from "../gh.js";
4
4
  import { parseGithubOriginRepo } from "../git.js";
@@ -6,6 +6,7 @@ import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defa
6
6
  import { buildPrBodyFromCommit } from "../pr-body.js";
7
7
  import { gitTopLevel } from "../repo-identity.js";
8
8
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
9
+ import { pollStackWatch } from "../stack-watch.js";
9
10
  /** A registered stack layer that would become the parent of a new layer. */
10
11
  export type RegisteredParentHit = {
11
12
  stackId: string;
@@ -120,6 +121,15 @@ export type StackSubmitDeps = {
120
121
  ensureUpperPark?: typeof ensureUpperPark;
121
122
  };
122
123
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
124
+ export declare function cmdStackWait(args: string[], deps?: {
125
+ loadConfig?: typeof loadConfig;
126
+ pollStackWatch?: typeof pollStackWatch;
127
+ signal?: AbortSignal;
128
+ }): Promise<void>;
129
+ export declare function cmdStackStatus(args: string[], deps?: {
130
+ loadConfig?: typeof loadConfig;
131
+ getEnrichedStack?: typeof getEnrichedStack;
132
+ }): Promise<void>;
123
133
  export type StackSetDeps = {
124
134
  loadConfig?: typeof loadConfig;
125
135
  setStackPolicy?: typeof setStackPolicy;
@@ -127,4 +137,6 @@ export type StackSetDeps = {
127
137
  /** Human summary lines for a `stack set` write, one per flag. */
128
138
  export declare function stackSetSummary(parsed: StackSetArgs): string[];
129
139
  export declare function cmdStackSet(args: string[], deps?: StackSetDeps): Promise<void>;
130
- export declare function cmdStack(args: string[]): Promise<void>;
140
+ export declare function cmdStack(args: string[], options?: {
141
+ signal?: AbortSignal;
142
+ }): Promise<void>;
@@ -1,12 +1,13 @@
1
- import { adoptStack, ensureUpperPark, landNextStack, listStacks, restackStack, setStackPolicy, } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, getEnrichedStack, landNextStack, listStacks, restackStack, setStackPolicy, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
- import { CommandError } from "../errors.js";
3
+ import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
4
4
  import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
5
  import { parseGithubOriginRepo } from "../git.js";
6
6
  import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
7
7
  import { buildPrBodyFromCommit } from "../pr-body.js";
8
8
  import { gitTopLevel } from "../repo-identity.js";
9
9
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
10
+ import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
10
11
  import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
11
12
  import { ansi } from "../ui/ansi.js";
12
13
  import { runLineTabsBrowser } from "../ui/line-tabs.js";
@@ -18,6 +19,8 @@ const STACK_USAGE = `usage:
18
19
  mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
19
20
  mergestorm stack reset --force
20
21
  mergestorm stack list [--json]
22
+ mergestorm stack status <stack-id> [--json]
23
+ mergestorm stack wait <stack-id> [--json] [--timeout <s>]
21
24
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
22
25
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
23
26
  mergestorm stack restack <stack-id> [--json]
@@ -143,7 +146,7 @@ export function stackPolicyLabels(s) {
143
146
  }
144
147
  return labels;
145
148
  }
146
- function formatStackHuman(s) {
149
+ function formatStackHuman(s, includeLayers = true) {
147
150
  const lines = [];
148
151
  lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}` +
149
152
  stackPolicyLabels(s)
@@ -154,6 +157,8 @@ function formatStackHuman(s) {
154
157
  lines.push(ansi.dim(" (no layers)"));
155
158
  return lines;
156
159
  }
160
+ if (!includeLayers)
161
+ return lines;
157
162
  for (const layer of s.layers) {
158
163
  const title = layer.title ? ` ${layer.title}` : "";
159
164
  const pr = layer.prNumber > 0 ? `#${layer.prNumber}` : "—";
@@ -787,6 +792,82 @@ async function cmdStackList(args) {
787
792
  listed.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
788
793
  await present("Stacks", listed);
789
794
  }
795
+ export async function cmdStackWait(args, deps = {}) {
796
+ const usage = "usage: mergestorm stack wait <stack-id> [--json] [--timeout <s>]";
797
+ const positional = [];
798
+ let timeoutS = 45;
799
+ for (let i = 0; i < args.length; i++) {
800
+ const arg = args[i];
801
+ if (arg === "--json")
802
+ continue;
803
+ if (arg === "--timeout" || arg.startsWith("--timeout=")) {
804
+ const value = arg === "--timeout" ? args[++i] : arg.slice("--timeout=".length);
805
+ timeoutS = value?.trim() ? Number(value) : NaN;
806
+ if (!Number.isFinite(timeoutS) || timeoutS < 0 || timeoutS > 300) {
807
+ throw new CommandError(`${usage}\n--timeout must be between 0 and 300 seconds`, REVIEW_EXIT.usage, "usage");
808
+ }
809
+ }
810
+ else if (arg.startsWith("-")) {
811
+ throw new CommandError(usage, REVIEW_EXIT.usage, "usage");
812
+ }
813
+ else {
814
+ positional.push(arg);
815
+ }
816
+ }
817
+ if (positional.length !== 1)
818
+ throw new CommandError(usage, REVIEW_EXIT.usage, "usage");
819
+ const stackId = requireStackId(positional[0], usage);
820
+ const cfg = await (deps.loadConfig ?? loadConfig)();
821
+ const print = (envelope, retryAfterSeconds) => console.log(args.includes("--json")
822
+ ? JSON.stringify({
823
+ ...envelope,
824
+ ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
825
+ }, null, 2)
826
+ : `Stack ${envelope.stackId} · ${envelope.status}${envelope.blocker ? ` · ${envelope.blocker}` : ""}`);
827
+ try {
828
+ print(await (deps.pollStackWatch ?? pollStackWatch)(cfg, stackId, {
829
+ timeoutMs: timeoutS * 1000,
830
+ signal: deps.signal,
831
+ }));
832
+ }
833
+ catch (err) {
834
+ if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
835
+ print(err.lastEnvelope, err.retryAfterSeconds);
836
+ throw new CommandError(rateLimitedMessage(err.retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds: err.retryAfterSeconds });
837
+ }
838
+ if (!(err instanceof StackWatchTimeoutError))
839
+ throw err;
840
+ print(err.lastEnvelope);
841
+ throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
842
+ }
843
+ }
844
+ export async function cmdStackStatus(args, deps = {}) {
845
+ const usage = "usage: mergestorm stack status <stack-id> [--json]";
846
+ const positional = args.filter((arg) => arg !== "--json");
847
+ if (positional.length !== 1)
848
+ throw new CommandError(usage, 2, "usage");
849
+ const stackId = requireStackId(positional[0], usage);
850
+ const cfg = await (deps.loadConfig ?? loadConfig)();
851
+ const stack = await (deps.getEnrichedStack ?? getEnrichedStack)(stackId, cfg);
852
+ if (!stack) {
853
+ throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
854
+ }
855
+ if (args.includes("--json")) {
856
+ console.log(JSON.stringify(stack, null, 2));
857
+ return;
858
+ }
859
+ const lines = formatStackHuman(stack, false);
860
+ for (const layer of stack.layers) {
861
+ const head = layer.headSha ? `@${layer.headSha.slice(0, 7)}` : "";
862
+ const pr = layer.prNumber > 0 ? `#${layer.prNumber}` : "—";
863
+ lines.push(` ${String(layer.position).padStart(2)} ${pr}${head} ${layer.state.padEnd(14)} ${layer.branch}` +
864
+ ` CI: ${layer.ciStatus ?? "unknown"} review: ${layer.reviewStatus ?? "unknown"}` +
865
+ (layer.vortexStatus ? ` Vortex: ${layer.vortexStatus}` : "") +
866
+ (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
867
+ (layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
868
+ }
869
+ await present("Stack status", lines);
870
+ }
790
871
  /** Human summary lines for a `stack set` write, one per flag. */
791
872
  export function stackSetSummary(parsed) {
792
873
  const tri = (value) => value === null ? "default (account setting)" : value ? "on" : "off";
@@ -885,7 +966,7 @@ async function cmdStackLand(args) {
885
966
  ` on ${id}`),
886
967
  ]);
887
968
  }
888
- export async function cmdStack(args) {
969
+ export async function cmdStack(args, options = {}) {
889
970
  const sub = args[0]?.toLowerCase();
890
971
  const rest = args.slice(1);
891
972
  // Help must exit 0 — print usage, do not throw CommandError.
@@ -905,6 +986,10 @@ export async function cmdStack(args) {
905
986
  return cmdStackReset(rest);
906
987
  if (sub === "list" || sub === "ls")
907
988
  return cmdStackList(rest);
989
+ if (sub === "status")
990
+ return cmdStackStatus(rest);
991
+ if (sub === "wait")
992
+ return cmdStackWait(rest, { signal: options.signal });
908
993
  if (sub === "set")
909
994
  return cmdStackSet(rest);
910
995
  if (sub === "adopt")
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "busy" | "adopt_failed";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;
@@ -21,6 +21,31 @@ export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" |
21
21
  * (#1947): Work paints Incomplete, never Reviewed or all clear.
22
22
  */
23
23
  export type StackVortexStatus = "queued" | "skipped" | "reviewing" | "seam_pending" | "all_clear" | "findings" | "throttled" | "failed" | "incomplete";
24
+ /**
25
+ * MS-14 viewer: the one lifecycle status of the Vortex review on a PR head.
26
+ * `pr_reviews` is the authority when a row exists for the SHA; a leftover
27
+ * `github_webhook_jobs` row paints `queued` / `reviewing` only when no pass row
28
+ * exists yet. `none` = never dispatched. `done` = a pass posted; the finer
29
+ * verdict detail lives on `vortexStatus` / `agentRuns`.
30
+ */
31
+ export type StackVortexReviewViewStatus = "none" | "queued" | "reviewing" | "skipped" | "done" | "failed";
32
+ export type StackVortexReviewView = {
33
+ status: StackVortexReviewViewStatus;
34
+ /** `pr_reviews.skip_reason` for `skipped`; null when the writer recorded none. */
35
+ skip_reason: string | null;
36
+ /** SHA + pass identity of the attempt (#2027); null before a pass row exists. */
37
+ pass: number | null;
38
+ /** Head the status speaks for; null when neither table named one. */
39
+ head_sha: string | null;
40
+ /** In-flight sub-state while `reviewing` (fanout / synthesizing). */
41
+ phase: string | null;
42
+ /** Earliest live job start while `reviewing`; null when lease-only. */
43
+ started_at: string | null;
44
+ /** Stop control visibility: true only while a pass can actually be cancelled. */
45
+ stoppable: boolean;
46
+ /** Which table spoke: the authority row, a live job, a lease, or nothing. */
47
+ source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
48
+ };
24
49
  export type StackCycloneStatus = "patching" | "awaiting_fix";
25
50
  export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
26
51
  export type StackAgentName = "vortex" | "cyclone" | "tempest";
@@ -140,6 +165,12 @@ export type StackLayerDto = {
140
165
  * Undefined = producer predates runs (clients fall back to the rollup).
141
166
  */
142
167
  agentRuns?: StackAgentRun[];
168
+ /**
169
+ * MS-14 viewer output for this layer's head. Every surface renders the
170
+ * lifecycle status from here; `vortexStatus` / `agentRuns` only add verdict
171
+ * detail once it says `done`. Undefined = producer predates the viewer.
172
+ */
173
+ vortexReview?: StackVortexReviewView;
143
174
  conflictDetail: string | null;
144
175
  restackError?: RestackError | null;
145
176
  lastRestackedSha: string | null;
@@ -249,6 +280,21 @@ export type StackDto = {
249
280
  * Missing on older payloads is off.
250
281
  */
251
282
  autoEnqueueWhenReady?: boolean;
283
+ /**
284
+ * Auto land Settling certificate. The stacks watcher publishes the identity
285
+ * of the settle clock it actually started (action, exact PR and head, server
286
+ * start time) and clears it when that clock is consumed or invalidated.
287
+ * Absent (older payloads) or `null` means no certified settle: paint nothing.
288
+ * Remaining time derives from `startedAt`; a refresh never restarts it.
289
+ * Partial or invalid server rows map to `null`, never to a guessed value.
290
+ */
291
+ autoEnqueueSettle?: {
292
+ action: "ready" | "promote";
293
+ prNumber: number;
294
+ headSha: string;
295
+ /** ISO timestamp of the watcher's clock start. */
296
+ startedAt: string;
297
+ } | null;
252
298
  /**
253
299
  * Per-stack Vortex auto-review override. `null` (or missing) follows the
254
300
  * account `auto_review_enabled` flag; a boolean wins in both directions.
@@ -286,7 +332,11 @@ export type StackDto = {
286
332
  export type MergeQueueEntryState = "queued" | "running" | "waiting" | "landed" | "bounced" | "cancelled";
287
333
  export type MergeQueueEnqueuedBy = "human" | "agent";
288
334
  export type MergeQueueEnqueuedVia = "dashboard" | "cli" | "chat" | "api" | "mcp";
289
- export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "tempest_findings" | "seam_findings" | "restack_conflict" | "pr_draft" | "merge_failed" | "gh_error";
335
+ export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "tempest_findings" | "seam_findings" | "restack_conflict" | "pr_draft" | "must_consolidate" | "merge_failed" | "gh_error";
336
+ /** Every bounce kind understood by the shared contract. */
337
+ export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "tempest_findings", "seam_findings", "restack_conflict", "pr_draft", "must_consolidate", "merge_failed", "gh_error"];
338
+ export declare const BOUNCE_KIND_LABELS: Readonly<Record<MergeQueueBounceKind, string>>;
339
+ export declare function mergeQueueBounceLabel(entry: MergeQueueEntryDto): string;
290
340
  /** Structured bounce handoff — enough for the bar chip and the chat quote. */
291
341
  export type MergeQueueBounceDetail = {
292
342
  kind: MergeQueueBounceKind;
package/dist/stack-dto.js CHANGED
@@ -1 +1,37 @@
1
- export {};
1
+ /** Every bounce kind understood by the shared contract. */
2
+ export const MERGE_QUEUE_BOUNCE_KINDS = [
3
+ "ci_failure",
4
+ "ci_timeout",
5
+ "head_moved",
6
+ "tempest_findings",
7
+ "seam_findings",
8
+ "restack_conflict",
9
+ "pr_draft",
10
+ "must_consolidate",
11
+ "merge_failed",
12
+ "gh_error",
13
+ ];
14
+ export const BOUNCE_KIND_LABELS = {
15
+ ci_failure: "CI failed",
16
+ ci_timeout: "CI timed out",
17
+ head_moved: "head moved",
18
+ tempest_findings: "Tempest findings",
19
+ seam_findings: "seam review findings",
20
+ restack_conflict: "restack conflict",
21
+ pr_draft: "draft PR",
22
+ must_consolidate: "needs promote",
23
+ merge_failed: "merge failed",
24
+ gh_error: "GitHub error",
25
+ };
26
+ export function mergeQueueBounceLabel(entry) {
27
+ const detail = entry.bounceDetail;
28
+ const kindLabel = detail ? BOUNCE_KIND_LABELS[detail.kind] : undefined;
29
+ if (kindLabel) {
30
+ const specifics = detail?.failingCheck ??
31
+ detail?.conflictBranch ??
32
+ detail?.message ??
33
+ (detail?.prNumber != null ? `PR #${detail.prNumber}` : null);
34
+ return specifics ? `${kindLabel} — ${specifics}` : kindLabel;
35
+ }
36
+ return entry.bounceReason?.trim() || "bounced";
37
+ }
@@ -0,0 +1,44 @@
1
+ import { apiFetch } from "./api.js";
2
+ import type { Config } from "./config.js";
3
+ export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
4
+ export declare const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45000;
5
+ /** Resume selectors are fixed for the entire wait, including transient retries. */
6
+ export type StackWatchCursor = {
7
+ readonly stackId: string;
8
+ readonly enrolledHeadSha: string | null;
9
+ readonly afterFinishedAt?: string | null;
10
+ readonly bounceId?: string | null;
11
+ };
12
+ export type StackWatchEnvelope = {
13
+ schema: typeof STACK_WATCH_SCHEMA;
14
+ status: "attention" | "waiting" | "in_progress" | "rate_limited" | "failed";
15
+ stackId: string;
16
+ blocker: string | null;
17
+ bounceKind: string | null;
18
+ prNumber: number | null;
19
+ headSha: string | null;
20
+ cursor: StackWatchCursor;
21
+ };
22
+ export type PollStackWatchOptions = {
23
+ timeoutMs?: number;
24
+ intervalMs?: number;
25
+ cursor?: StackWatchCursor;
26
+ signal?: AbortSignal;
27
+ onTick?: (envelope: StackWatchEnvelope) => void;
28
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
29
+ now?: () => number;
30
+ random?: () => number;
31
+ /** Test seam at the apiFetch boundary, including HTTP status and Retry-After. */
32
+ fetch?: typeof apiFetch;
33
+ };
34
+ export declare class StackWatchTimeoutError extends Error {
35
+ readonly lastEnvelope: StackWatchEnvelope;
36
+ constructor(lastEnvelope: StackWatchEnvelope);
37
+ }
38
+ export declare class StackWatchError extends Error {
39
+ readonly lastEnvelope: StackWatchEnvelope;
40
+ readonly retryAfterSeconds?: number | undefined;
41
+ constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined);
42
+ }
43
+ /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
44
+ export declare function pollStackWatch(cfg: Config, stackId: string, opts?: PollStackWatchOptions): Promise<StackWatchEnvelope>;
@@ -0,0 +1,232 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { apiFetch } from "./api.js";
3
+ import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
4
+ import { mergeQueueBounceLabel } from "./stack-dto.js";
5
+ export const STACK_WATCH_SCHEMA = "mergestorm.stack_watch/v1";
6
+ export const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45_000;
7
+ export class StackWatchTimeoutError extends Error {
8
+ lastEnvelope;
9
+ constructor(lastEnvelope) {
10
+ super(`Timed out waiting for stack ${lastEnvelope.stackId}`);
11
+ this.lastEnvelope = lastEnvelope;
12
+ this.name = "StackWatchTimeoutError";
13
+ }
14
+ }
15
+ export class StackWatchError extends Error {
16
+ lastEnvelope;
17
+ retryAfterSeconds;
18
+ constructor(message, lastEnvelope, options, retryAfterSeconds) {
19
+ super(message, options);
20
+ this.lastEnvelope = lastEnvelope;
21
+ this.retryAfterSeconds = retryAfterSeconds;
22
+ this.name = "StackWatchError";
23
+ }
24
+ }
25
+ function sameHead(a, b) {
26
+ const left = a?.trim().toLowerCase();
27
+ const right = b?.trim().toLowerCase();
28
+ return !!left && !!right && (left.startsWith(right) || right.startsWith(left));
29
+ }
30
+ function currentLayer(stack) {
31
+ const open = stack.layers.filter((layer) => layer.prNumber > 0 && layer.state !== "merged" && layer.state !== "closed");
32
+ const promote = open.filter((layer) => layer.prNumber !== stack.unit?.landPrNumber &&
33
+ !stack.unit?.members.some((member) => member.prNumber === layer.prNumber && member.promotedHeadSha)).sort((a, b) => a.position - b.position);
34
+ const land = stack.unit?.landPr;
35
+ return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
36
+ (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
37
+ }
38
+ /** Keep label text and precedence aligned with MCP stack-summary.blockerLabel. */
39
+ function attention(stack, layer, queue, cursor) {
40
+ const none = { blocker: null, bounceKind: null };
41
+ if (stack.archivedAt || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
42
+ return none;
43
+ const bounce = queue.filter((entry) => entry.state === "bounced")
44
+ .sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
45
+ const kind = bounce?.bounceDetail?.kind;
46
+ const recoverable = kind === "head_moved" || kind === "must_consolidate";
47
+ const recoverableSha = kind === "head_moved"
48
+ ? bounce?.bounceDetail?.headSha ?? bounce?.verifyHeadSha
49
+ : bounce?.verifyHeadSha ?? bounce?.bounceDetail?.headSha;
50
+ const currentRecoverable = recoverable && sameHead(layer?.headSha, recoverableSha);
51
+ const hardBlock = (blocker) => ({ blocker, bounceKind: null });
52
+ if (layer) {
53
+ if (layer.restackError && layer.state !== "restacking") {
54
+ return hardBlock(layer.restackError.kind === "rebase_conflict" ? "Conflict" : "Restack failed");
55
+ }
56
+ if (layer.state === "conflict")
57
+ return hardBlock("Conflict");
58
+ if (layer.draft)
59
+ return hardBlock("Draft PR");
60
+ if ((!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
61
+ (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty"))
62
+ return hardBlock("Merge conflicts");
63
+ if (!currentRecoverable && (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0)) {
64
+ const name = layer.checks?.failingName?.trim();
65
+ return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
66
+ }
67
+ const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
68
+ ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
69
+ if (tempest)
70
+ return hardBlock(`Tempest ${tempest.status}`);
71
+ if (layer.vortexStatus === "failed")
72
+ return hardBlock("Review failed");
73
+ if (stack.unit?.landPrNumber === layer.prNumber) {
74
+ if (stack.unit.landingBlockReason?.trim())
75
+ return hardBlock(stack.unit.landingBlockReason.trim());
76
+ if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
77
+ return hardBlock("Tempest failed");
78
+ }
79
+ }
80
+ if (!bounce || !kind || recoverable || !layer)
81
+ return none;
82
+ if (cursor.bounceId === bounce.id || (cursor.afterFinishedAt &&
83
+ !(Date.parse(bounce.finishedAt ?? "") > Date.parse(cursor.afterFinishedAt))))
84
+ return none;
85
+ // Compare with the observed promote/land head, never the enrollment or verify head.
86
+ if (!sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) ||
87
+ (bounce.bounceDetail?.prNumber != null && bounce.bounceDetail.prNumber !== layer.prNumber))
88
+ return none;
89
+ return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
90
+ }
91
+ /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
92
+ export async function pollStackWatch(cfg, stackId, opts = {}) {
93
+ const id = stackId.trim().toLowerCase();
94
+ if (!id || (opts.cursor && opts.cursor.stackId.trim().toLowerCase() !== id)) {
95
+ throw new TypeError("A matching stackId is required");
96
+ }
97
+ const timeoutMs = opts.timeoutMs ?? STACK_WATCH_DEFAULT_TIMEOUT_MS;
98
+ const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
99
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) {
100
+ throw new TypeError("timeoutMs must be nonnegative and intervalMs must be positive");
101
+ }
102
+ const now = opts.now ?? Date.now;
103
+ const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
104
+ const fetch = opts.fetch ?? apiFetch;
105
+ const startedAt = now();
106
+ const deadline = startedAt + timeoutMs;
107
+ let enrolled = opts.cursor !== undefined;
108
+ let cursor = Object.freeze({
109
+ ...(opts.cursor ?? { stackId: id, enrolledHeadSha: null }),
110
+ stackId: id,
111
+ });
112
+ let lastEnvelope = {
113
+ schema: STACK_WATCH_SCHEMA, status: "waiting", stackId: id, blocker: null,
114
+ bounceKind: null, prNumber: null, headSha: null, cursor,
115
+ };
116
+ let transientFailures = 0;
117
+ const timeout = () => new StackWatchTimeoutError({ ...lastEnvelope, status: "waiting" });
118
+ const pause = async (ms) => {
119
+ const remaining = Math.min(ms, Math.max(0, deadline - now()));
120
+ if (remaining > 0)
121
+ await wait(remaining, opts.signal);
122
+ };
123
+ const request = async (route, finalSnapshot = false) => {
124
+ let readAfterDeadline = finalSnapshot;
125
+ while (readAfterDeadline || now() < deadline) {
126
+ readAfterDeadline = false;
127
+ opts.signal?.throwIfAborted();
128
+ let response;
129
+ try {
130
+ response = await fetch(cfg, route, {
131
+ signal: opts.signal, timeoutMs: route.includes("&wait=")
132
+ ? Number(new URL(route, "https://local").searchParams.get("wait")) * 1000 + 5_000
133
+ : finalSnapshot ? 5_000 : Math.max(1, Math.min(30_000, deadline - now())),
134
+ });
135
+ }
136
+ catch (err) {
137
+ if (opts.signal?.aborted || (err instanceof Error && err.name === "AbortError"))
138
+ throw err;
139
+ if (now() >= deadline)
140
+ throw timeout();
141
+ if (isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
142
+ transientFailures += 1;
143
+ opts.onTick?.(lastEnvelope);
144
+ await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
145
+ continue;
146
+ }
147
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed" }, { cause: err });
148
+ }
149
+ if (response.status === 200)
150
+ return response.body;
151
+ if (isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
152
+ transientFailures += 1;
153
+ opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
154
+ await pause(transientRetryWaitMs({
155
+ failureCount: transientFailures, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
156
+ }));
157
+ continue;
158
+ }
159
+ throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
160
+ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
161
+ }, undefined, response.retryAfterSeconds);
162
+ }
163
+ throw timeout();
164
+ };
165
+ while (now() < deadline) {
166
+ const snapshot = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`);
167
+ let stack = snapshot && Array.isArray(snapshot.stacks)
168
+ ? snapshot.stacks.find((stack) => stack.id.trim().toLowerCase() === id)
169
+ : undefined;
170
+ if (!stack || !Array.isArray(stack.layers)) {
171
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed" });
172
+ }
173
+ let layer = currentLayer(stack);
174
+ if (!enrolled) {
175
+ cursor = Object.freeze({ stackId: id, enrolledHeadSha: layer?.headSha ?? null });
176
+ enrolled = true;
177
+ }
178
+ lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
179
+ const initialQueue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`);
180
+ if (!initialQueue || !Array.isArray(initialQueue.entries)) {
181
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed" });
182
+ }
183
+ const initialEntries = initialQueue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
184
+ const initial = attention(stack, layer, initialEntries, cursor);
185
+ if (initial.blocker) {
186
+ const { bounce, ...result } = initial;
187
+ if (bounce) {
188
+ cursor = Object.freeze({
189
+ ...cursor,
190
+ bounceId: bounce.id,
191
+ ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}),
192
+ });
193
+ }
194
+ return { ...lastEnvelope, ...result, cursor, status: "attention" };
195
+ }
196
+ const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - now()) / 1000)));
197
+ const queue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}&wait=${seconds}`);
198
+ if (!queue || !Array.isArray(queue.entries)) {
199
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed" });
200
+ }
201
+ if (now() < deadline) {
202
+ const refreshed = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, true);
203
+ stack = Array.isArray(refreshed?.stacks)
204
+ ? refreshed.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
205
+ : undefined;
206
+ if (!stack || !Array.isArray(stack.layers)) {
207
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed" });
208
+ }
209
+ layer = currentLayer(stack);
210
+ lastEnvelope = { ...lastEnvelope, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
211
+ }
212
+ transientFailures = 0;
213
+ const entries = queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
214
+ const { bounce, ...result } = attention(stack, layer, entries, cursor);
215
+ if (bounce) {
216
+ cursor = Object.freeze({
217
+ ...cursor,
218
+ bounceId: bounce.id,
219
+ ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}),
220
+ });
221
+ }
222
+ lastEnvelope = {
223
+ ...lastEnvelope, ...result, cursor,
224
+ status: result.blocker ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
225
+ ? "in_progress" : "waiting",
226
+ };
227
+ if (lastEnvelope.status === "attention")
228
+ return lastEnvelope;
229
+ opts.onTick?.(lastEnvelope);
230
+ }
231
+ throw timeout();
232
+ }
package/dist/ui/banner.js CHANGED
@@ -90,6 +90,9 @@ const WHATS_NEW = {
90
90
  "0.3.18": [
91
91
  "pr --wait fences a stale same-head pass",
92
92
  ],
93
+ "0.3.19": [
94
+ "stack wait and held GET waits",
95
+ ],
93
96
  };
94
97
  function densityFromVariant(variant) {
95
98
  if (variant === "mini" || variant === "nano")