mergestorm 0.3.18 → 0.3.20

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.
@@ -0,0 +1,163 @@
1
+ import { currentLayer, stackBlockers } from "./stack-blockers.js";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { apiFetch } from "./api.js";
4
+ import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.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
+ /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
26
+ export async function pollStackWatch(cfg, stackId, opts = {}) {
27
+ const id = stackId.trim().toLowerCase();
28
+ if (!id || (opts.cursor && opts.cursor.stackId.trim().toLowerCase() !== id)) {
29
+ throw new TypeError("A matching stackId is required");
30
+ }
31
+ const timeoutMs = opts.timeoutMs ?? STACK_WATCH_DEFAULT_TIMEOUT_MS;
32
+ const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) {
34
+ throw new TypeError("timeoutMs must be nonnegative and intervalMs must be positive");
35
+ }
36
+ const now = opts.now ?? Date.now;
37
+ const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
38
+ const fetch = opts.fetch ?? apiFetch;
39
+ const startedAt = now();
40
+ const deadline = startedAt + timeoutMs;
41
+ let enrolled = opts.cursor !== undefined;
42
+ let cursor = Object.freeze({
43
+ ...(opts.cursor ?? { stackId: id, enrolledHeadSha: null }),
44
+ stackId: id,
45
+ });
46
+ let lastEnvelope = {
47
+ schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
48
+ bounceKind: null, prNumber: null, headSha: null, cursor,
49
+ issues: [], currentCandidate: null, assessment: "unavailable",
50
+ };
51
+ let transientFailures = 0;
52
+ const timeout = () => new StackWatchTimeoutError(lastEnvelope);
53
+ const pause = async (ms) => {
54
+ const remaining = Math.min(ms, Math.max(0, deadline - now()));
55
+ if (remaining > 0)
56
+ await wait(remaining, opts.signal);
57
+ };
58
+ const request = async (route, finalSnapshot = false) => {
59
+ let readAfterDeadline = finalSnapshot;
60
+ while (readAfterDeadline || now() < deadline) {
61
+ readAfterDeadline = false;
62
+ opts.signal?.throwIfAborted();
63
+ let response;
64
+ try {
65
+ response = await fetch(cfg, route, {
66
+ signal: opts.signal, timeoutMs: route.includes("&wait=")
67
+ ? Number(new URL(route, "https://local").searchParams.get("wait")) * 1000 + 5_000
68
+ : finalSnapshot ? 5_000 : Math.max(1, Math.min(30_000, deadline - now())),
69
+ });
70
+ }
71
+ catch (err) {
72
+ if (opts.signal?.aborted || (err instanceof Error && err.name === "AbortError"))
73
+ throw err;
74
+ if (now() >= deadline && timeoutMs !== 0)
75
+ throw timeout();
76
+ if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
77
+ transientFailures += 1;
78
+ opts.onTick?.(lastEnvelope);
79
+ await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
80
+ continue;
81
+ }
82
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed", assessment: "unavailable" }, { cause: err });
83
+ }
84
+ if (response.status === 200)
85
+ return response.body;
86
+ const retryWaitMs = transientRetryWaitMs({
87
+ failureCount: transientFailures + 1, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
88
+ });
89
+ if (timeoutMs !== 0 && isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES &&
90
+ (response.status !== 429 || retryWaitMs < deadline - now())) {
91
+ transientFailures += 1;
92
+ opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
93
+ await pause(retryWaitMs);
94
+ continue;
95
+ }
96
+ throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
97
+ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
98
+ assessment: "unavailable",
99
+ }, undefined, response.retryAfterSeconds);
100
+ }
101
+ throw timeout();
102
+ };
103
+ let firstSnapshot = true;
104
+ while (firstSnapshot || now() < deadline) {
105
+ firstSnapshot = false;
106
+ const snapshot = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, timeoutMs === 0);
107
+ let stack = snapshot && Array.isArray(snapshot.stacks)
108
+ ? snapshot.stacks.find((stack) => stack.id.trim().toLowerCase() === id)
109
+ : undefined;
110
+ if (!stack || !Array.isArray(stack.layers)) {
111
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
112
+ }
113
+ let layer = currentLayer(stack);
114
+ if (!enrolled) {
115
+ cursor = Object.freeze({ stackId: id, enrolledHeadSha: layer?.headSha ?? null });
116
+ enrolled = true;
117
+ }
118
+ lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
119
+ const initialQueue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`, timeoutMs === 0);
120
+ if (!initialQueue || !Array.isArray(initialQueue.entries)) {
121
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
122
+ }
123
+ const initialEntries = initialQueue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
124
+ const evaluate = (stack, entries) => {
125
+ const { attention, issues, currentCandidate, bounce } = stackBlockers(stack, entries, cursor);
126
+ if (bounce)
127
+ cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
128
+ ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
129
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available",
130
+ blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
131
+ prNumber: attention?.prNumber ?? currentCandidate?.prNumber ?? null,
132
+ headSha: attention ? attention.headSha : currentCandidate?.headSha ?? null,
133
+ status: attention ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
134
+ ? "in_progress" : "waiting" };
135
+ };
136
+ evaluate(stack, initialEntries);
137
+ if (lastEnvelope.status === "attention" || timeoutMs === 0)
138
+ return lastEnvelope;
139
+ const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - now()) / 1000)));
140
+ const queue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}&wait=${seconds}`);
141
+ if (!queue || !Array.isArray(queue.entries)) {
142
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
143
+ }
144
+ if (now() < deadline) {
145
+ const refreshed = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, true);
146
+ stack = Array.isArray(refreshed?.stacks)
147
+ ? refreshed.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
148
+ : undefined;
149
+ if (!stack || !Array.isArray(stack.layers)) {
150
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
151
+ }
152
+ layer = currentLayer(stack);
153
+ lastEnvelope = { ...lastEnvelope, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
154
+ }
155
+ transientFailures = 0;
156
+ const entries = queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
157
+ evaluate(stack, entries);
158
+ if (lastEnvelope.status === "attention")
159
+ return lastEnvelope;
160
+ opts.onTick?.(lastEnvelope);
161
+ }
162
+ throw timeout();
163
+ }
package/dist/ui/banner.js CHANGED
@@ -90,6 +90,12 @@ 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
+ ],
96
+ "0.3.20": [
97
+ "stack watch pair-gate attention and upstack issues",
98
+ ],
93
99
  };
94
100
  function densityFromVariant(variant) {
95
101
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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",
package/skill/SKILL.md CHANGED
@@ -13,17 +13,17 @@ Before opening a PR; after a large refactor; when the user says review, audit, s
13
13
 
14
14
  Not for merging, landing, or pushing. For a PR that is already open on GitHub, use the `mergestorm-pr-loop` skill instead.
15
15
 
16
- ## Install (Claude Code / Cursor)
16
+ ## Install (Claude Code / Cursor / Agents)
17
17
 
18
18
  ```bash
19
19
  mg skill install --claude
20
20
  mg skill install --cursor
21
- # or both: mg skill install --claude --cursor
21
+ # or all three: mg skill install --claude --cursor --agents
22
22
  ```
23
23
 
24
- That writes `.claude/skills/mergestorm-review/SKILL.md` and/or `.cursor/skills/mergestorm-review/SKILL.md` in the current repo.
24
+ That writes `.claude/skills/mergestorm-review/SKILL.md`, `.cursor/skills/mergestorm-review/SKILL.md`, and/or `.agents/skills/mergestorm-review/SKILL.md` in the current repo.
25
25
 
26
- Hand-copy still works: Claude Code project skill `.claude/skills/mergestorm-review/SKILL.md` (or `~/.claude/skills/mergestorm-review/SKILL.md`). Cursor: the same file under `.cursor/skills/mergestorm-review/SKILL.md`; otherwise a rule at `.cursor/rules/mergestorm-review.mdc` with this description and `alwaysApply: false`.
26
+ Hand-copy still works: Claude Code project skill `.claude/skills/mergestorm-review/SKILL.md` (or `~/.claude/skills/mergestorm-review/SKILL.md`). Cursor: the same file under `.cursor/skills/mergestorm-review/SKILL.md`; otherwise a rule at `.cursor/rules/mergestorm-review.mdc` with this description and `alwaysApply: false`. Agents: `.agents/skills/mergestorm-review/SKILL.md`.
27
27
 
28
28
  MCP server:
29
29
 
@@ -51,6 +51,8 @@ Cursor `~/.cursor/mcp.json`:
51
51
  1. Prefer the `mergestorm` MCP server. Call `whoami`. If MCP is missing, require `mg` on PATH with `mg whoami --json` exit 0. If neither is available, stop and print the install one-liner above.
52
52
  2. Call `credits` (or `mg credits --json`). If remaining standard credits are 0, say so and stop.
53
53
 
54
+ Ignored bot authors live in Settings → Vortex ("Ignore bot PR authors"), not Agents. Local `review_submit` is a manual review and is not an ignore-list skip.
55
+
54
56
  ## Hire policy
55
57
 
56
58
  Default: omit `router` (account setting).
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: mergestorm-bounce-watch
3
+ description: Watch stacks explicitly named by the human while Auto land is on, and patch the current blocker after a bounce. Use for an ongoing stack watch, without taking over Cyclone or landing the stack.
4
+ ---
5
+
6
+ # Mergestorm bounce watch
7
+
8
+ Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
9
+
10
+ ## Scope and enrollment
11
+
12
+ 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.
13
+
14
+ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady` is `true` (Auto land on). If Auto land is off, missing, or cannot be verified, stop and tell the human. Never enable it yourself. Recheck this condition between wait slices and before patching or pushing. Stop when the stack is archived or its PRs are all merged or closed.
15
+
16
+ ## Watch and patch
17
+
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). The shipped tool accepts these selectors, not a nested cursor argument.
19
+ 2. 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`. 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.
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. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy.
21
+ 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 — 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.
22
+ 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. 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.
23
+ 6. Recheck Auto land and the remote PR head 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.
24
+ 7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain its `finishedAt` as `after_finished_at` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
25
+
26
+ ## Bounce kinds
27
+
28
+ The kind list is exactly `MERGE_QUEUE_BOUNCE_KINDS` from stack-dto:
29
+
30
+ - `ci_failure`
31
+ - `ci_timeout`
32
+ - `head_moved`
33
+ - `tempest_findings`
34
+ - `seam_findings`
35
+ - `restack_conflict`
36
+ - `pr_draft`
37
+ - `must_consolidate`
38
+ - `merge_failed`
39
+ - `gh_error`
40
+
41
+ A kind is context, not permission to mutate queue or stack policy. Diagnose the current blocker rather than blindly retrying its old bounce. A head-moved bounce requires stopping if another actor moved the PR head; a timeout alone does not justify a code change. If there is no concrete patch within this scope, report the blocker and stop.
42
+
43
+ ## Never
44
+
45
+ - Do not land, merge PRs, enqueue, run `mg queue add`, `stack land`, or `land-unit`, or call `queue_enqueue` (not a shipped tool).
46
+ - Do not call `settings_set`, change account auto-patch settings, or turn account auto-patch off.
47
+ - Do not set `auto_patch: true` on a stack.
48
+ - Do not replace Cyclone or change stack auto-patch policy to take over its work.
49
+ - Do not auto re-arm Auto land or a bounced queue entry.
50
+ - Do not force-push except the same-live-parent rebase with the explicit starting-SHA lease above. Never rebase and retry after the remote head moved.
51
+ - Do not push any branch other than the named blocker's PR head (or the one selected live CI issue after the gate is clean).
@@ -9,10 +9,12 @@ Vortex already reviews every push to an open PR on a monitored repo; nothing her
9
9
 
10
10
  Two worlds, never mixed: reviewing a local branch before a PR exists is the `mergestorm-review` skill. This skill is only for a PR that is already open on GitHub.
11
11
 
12
- ## Install (Claude Code / Cursor)
12
+ The account ignore list and check colors live on Settings; manage the list with `mg settings --ignore-bot add <login>|remove <login>|clear`. The ignore list does not change this loop or its rule against setting `auto_patch_enabled` with `settings_set`.
13
+
14
+ ## Install (Claude Code / Cursor / Agents)
13
15
 
14
16
  ```bash
15
- mg skill install --claude --cursor
17
+ mg skill install --claude --cursor --agents
16
18
  ```
17
19
 
18
20
  ## Preconditions (in order)
@@ -25,6 +27,7 @@ mg skill install --claude --cursor
25
27
  ## Loop (in order)
26
28
 
27
29
  1. Find the PR's stack with `stack_list` (the stack whose `layers` contain this PR's `prNumber` for this owner/repo). If you already have its `stack_id`, `stack_status` is the same payload.
30
+ - Adoption needs the Cyclone GitHub App on the account; without it `stack_adopt` returns `cyclone_not_connected` — stop and tell the human, do not retry.
28
31
  - If there is no stack and the human asked you to mute Cyclone or continue the loop: call `stack_adopt` with `{ owner, repo, pr_number, auto_patch: false }` (equivalent to `mg stack adopt … --auto-patch off`). Then call `stack_status` with the returned `result.stack.id`, confirm this PR is in the stack and `autoPatchOverride` is `false`. If adoption or verification fails, refuse the loop. Otherwise continue through the ownership check below and the remaining loop steps; do not stop and ask the human to adopt. Do not turn account auto-patch off.
29
32
  - Adoption does not fix installer ≠ key. After adoption, the same `cycloneOwnerMatch` refusal below still applies.
30
33
  - Read `cycloneOwnerMatch` on that stack. If it is missing, `lookup_failed`, or `different`: refuse the loop. Do not wait, patch, or push. Tell the human the API key and the Cyclone GitHub App install are different Mergestorm accounts (or the comparison could not be loaded). Cyclone still patches as the installer. Do not turn account auto-patch off, and do not set `auto_patch: false` to paper over this.
@@ -53,12 +56,14 @@ mg skill install --claude --cursor
53
56
 
54
57
  Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
55
58
 
56
- - `finding_count` is 0 (empty inline and offDiff): stop. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
59
+ - `finding_count` is 0 and (the pass verdict is `comment` or a finding title in `findings.inline` or `findings.offDiff` is Decision required): stop. Surface the pass, and quote that note when it is present. Do not patch.
60
+ - `finding_count` is 0 and the pass verdict is `approve`: stop. A skipped GitHub review body (account Skip All-clear) is still a completed pass — do not wait for an Approve / All-clear comment. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
57
61
  - status is `rate_limited`, or `patch_policy.mode` is `"hold"`: surface the findings to the human and stop. Do not patch.
58
62
  - status is `failed`: report to the human. Do not keep pushing to retrigger.
59
63
  - status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha` and the same `after_pass`.
60
64
  - status is `in_progress` and the envelope has no `head_sha`: the wait timed out before Vortex wrote a pass for your SHA. Call `review_wait_pr` once more with the same `after_sha` and the same `after_pass`. If it comes back the same way a second time in a row, stop and tell the human that Vortex did not pick up the push. Do not push again to retrigger.
61
- - status is `skipped`, `stopped`, `trial_expired`, or `synced`: surface the status and reason to the human and stop. Do not patch or push.
65
+ - status is `quota_exceeded` (or its legacy spelling `trial_expired`): the account's review credits are used up for this period; tell the human calmly and stop. It is not an error and not a lapsed trial. Do not patch or push.
66
+ - status is `skipped`, `stopped`, or `synced`: surface the status and reason to the human and stop. Do not patch or push.
62
67
 
63
68
  ## Never
64
69