mergestorm 0.3.20 → 0.3.22
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 +2 -2
- package/dist/api.d.ts +3 -1
- package/dist/api.js +7 -3
- package/dist/stack-dto.d.ts +1 -1
- package/dist/stack-watch.js +49 -46
- package/dist/ui/banner.js +3 -0
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +6 -6
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.
|
|
48
|
+
│ ▟██▛ mergestorm v0.3.22 │
|
|
49
49
|
│ ▜▙ ● msk_live_… · maelstrom │
|
|
50
50
|
│ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
|
|
51
51
|
│ │
|
|
52
52
|
│ review a diff · usage for tabs · /help for all commands │
|
|
53
|
-
│ New in v0.3.
|
|
53
|
+
│ New in v0.3.22: stack wait holds an unchanged queue instead of spinning│
|
|
54
54
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
55
55
|
mergestorm
|
|
56
56
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
package/dist/api.d.ts
CHANGED
|
@@ -239,7 +239,9 @@ export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
|
|
|
239
239
|
prNumber?: number;
|
|
240
240
|
}): Promise<unknown>;
|
|
241
241
|
/** List the authenticated user's live merge-queue entries. */
|
|
242
|
-
export declare function listMergeQueueEntries(cfg?: Config
|
|
242
|
+
export declare function listMergeQueueEntries(cfg?: Config, opts?: {
|
|
243
|
+
stackId?: string;
|
|
244
|
+
}): Promise<MergeQueueEntryDto[]>;
|
|
243
245
|
/** Enqueue a stack for verified landing. */
|
|
244
246
|
export declare function enqueueStack(stackId: string, cfg?: Config): Promise<unknown>;
|
|
245
247
|
/** Cancel a live merge-queue entry by entry id. */
|
package/dist/api.js
CHANGED
|
@@ -347,7 +347,7 @@ export async function getEnrichedStack(stackId, cfg, opts) {
|
|
|
347
347
|
if (!Array.isArray(stacks)) {
|
|
348
348
|
throw new CommandError(`Failed to get stack status (HTTP 200): ${JSON.stringify(body)}`);
|
|
349
349
|
}
|
|
350
|
-
return stacks.find((stack) => stack?.id === id) ?? null;
|
|
350
|
+
return stacks.find((stack) => typeof stack?.id === "string" && stack.id.trim().toLowerCase() === id.toLowerCase()) ?? null;
|
|
351
351
|
}
|
|
352
352
|
/** Drop undefined keys so the wire body only carries what was requested. */
|
|
353
353
|
export function stackPolicyBody(policy) {
|
|
@@ -485,12 +485,16 @@ export async function landNextStack(stackId, cfg, opts) {
|
|
|
485
485
|
return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
|
|
486
486
|
}
|
|
487
487
|
/** List the authenticated user's live merge-queue entries. */
|
|
488
|
-
export async function listMergeQueueEntries(cfg) {
|
|
488
|
+
export async function listMergeQueueEntries(cfg, opts) {
|
|
489
489
|
const resolved = cfg ?? (await loadConfig());
|
|
490
|
-
const
|
|
490
|
+
const stackId = opts?.stackId?.trim();
|
|
491
|
+
const { status, body, retryAfterSeconds } = await apiFetch(resolved, stackId ? `/api/v1/stacks/queue?stackId=${encodeURIComponent(stackId)}` : "/api/v1/stacks/queue");
|
|
491
492
|
if (status === 404) {
|
|
492
493
|
throw new CommandError("Merge queue API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
493
494
|
}
|
|
495
|
+
if (status === 429) {
|
|
496
|
+
throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
|
|
497
|
+
}
|
|
494
498
|
if (status !== 200) {
|
|
495
499
|
throw new CommandError(`Failed to list merge queue (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
496
500
|
}
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export type StackVortexReviewView = {
|
|
|
57
57
|
/** Which table spoke: the authority row, a live job, a lease, or nothing. */
|
|
58
58
|
source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
|
|
59
59
|
};
|
|
60
|
-
export type StackCycloneStatus = "patching" | "awaiting_fix";
|
|
60
|
+
export type StackCycloneStatus = "patching" | "awaiting_fix" | "failed";
|
|
61
61
|
export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
|
|
62
62
|
export type StackAgentName = "vortex" | "cyclone" | "tempest";
|
|
63
63
|
/**
|
package/dist/stack-watch.js
CHANGED
|
@@ -100,61 +100,64 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
throw timeout();
|
|
102
102
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let stack = snapshot && Array.isArray(snapshot.stacks)
|
|
108
|
-
? snapshot.stacks.find((stack) => stack.id.trim().toLowerCase() === id)
|
|
103
|
+
const enrich = async (finalSnapshot) => {
|
|
104
|
+
const snapshot = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, finalSnapshot);
|
|
105
|
+
const stack = snapshot && Array.isArray(snapshot.stacks)
|
|
106
|
+
? snapshot.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
|
|
109
107
|
: undefined;
|
|
110
108
|
if (!stack || !Array.isArray(stack.layers)) {
|
|
111
109
|
throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
|
|
112
110
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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}`);
|
|
111
|
+
return stack;
|
|
112
|
+
};
|
|
113
|
+
const queueRoute = `/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`;
|
|
114
|
+
const readQueue = async (route, finalSnapshot = false) => {
|
|
115
|
+
const queue = await request(route, finalSnapshot);
|
|
141
116
|
if (!queue || !Array.isArray(queue.entries)) {
|
|
142
117
|
throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
|
|
143
118
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
119
|
+
return {
|
|
120
|
+
entries: queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id),
|
|
121
|
+
fingerprint: typeof queue.fingerprint === "string" && queue.fingerprint ? queue.fingerprint : null,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
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
|
+
let stack = await enrich(timeoutMs === 0);
|
|
137
|
+
const layer = currentLayer(stack);
|
|
138
|
+
if (!enrolled) {
|
|
139
|
+
cursor = Object.freeze({ stackId: id, enrolledHeadSha: layer?.headSha ?? null });
|
|
140
|
+
enrolled = true;
|
|
141
|
+
}
|
|
142
|
+
lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
|
|
143
|
+
let queue = await readQueue(queueRoute, timeoutMs === 0);
|
|
144
|
+
evaluate(stack, queue.entries);
|
|
145
|
+
if (lastEnvelope.status === "attention" || timeoutMs === 0)
|
|
146
|
+
return lastEnvelope;
|
|
147
|
+
while (now() < deadline) {
|
|
148
|
+
const heldAt = now();
|
|
149
|
+
const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
|
|
150
|
+
const seen = queue.fingerprint ? `&seen=${encodeURIComponent(queue.fingerprint)}` : "";
|
|
151
|
+
const held = await readQueue(`${queueRoute}&wait=${seconds}${seen}`);
|
|
152
|
+
if (JSON.stringify(held.entries) === JSON.stringify(queue.entries)) {
|
|
153
|
+
await pause(intervalMs - (now() - heldAt));
|
|
154
154
|
}
|
|
155
|
+
queue = held;
|
|
156
|
+
stack = await enrich(true);
|
|
157
|
+
const refreshed = currentLayer(stack);
|
|
158
|
+
lastEnvelope = { ...lastEnvelope, prNumber: refreshed?.prNumber ?? null, headSha: refreshed?.headSha ?? null };
|
|
155
159
|
transientFailures = 0;
|
|
156
|
-
|
|
157
|
-
evaluate(stack, entries);
|
|
160
|
+
evaluate(stack, queue.entries);
|
|
158
161
|
if (lastEnvelope.status === "attention")
|
|
159
162
|
return lastEnvelope;
|
|
160
163
|
opts.onTick?.(lastEnvelope);
|
package/dist/ui/banner.js
CHANGED
|
@@ -96,6 +96,9 @@ const WHATS_NEW = {
|
|
|
96
96
|
"0.3.20": [
|
|
97
97
|
"stack watch pair-gate attention and upstack issues",
|
|
98
98
|
],
|
|
99
|
+
"0.3.22": [
|
|
100
|
+
"stack wait holds an unchanged queue instead of spinning",
|
|
101
|
+
],
|
|
99
102
|
};
|
|
100
103
|
function densityFromVariant(variant) {
|
|
101
104
|
if (variant === "mini" || variant === "nano")
|
package/package.json
CHANGED
|
@@ -11,17 +11,17 @@ Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queu
|
|
|
11
11
|
|
|
12
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
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.
|
|
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. If Auto land is off when you recheck, call `queue_status` with the same `stack_id` and report the newest bounce (kind, PR, head, `finishedAt`) before stopping: `merge_failed`, `gh_error`, and `ci_timeout` can disarm Auto land. Stop when the stack is archived or its PRs are all merged or closed.
|
|
15
15
|
|
|
16
16
|
## Watch and patch
|
|
17
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.
|
|
18
|
+
1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument.
|
|
19
|
+
2. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
|
|
20
|
+
3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy.
|
|
21
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
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
|
|
23
|
+
6. Recheck Auto land and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
|
|
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 the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
|
|
25
25
|
|
|
26
26
|
## Bounce kinds
|
|
27
27
|
|