mergestorm-mcp 0.2.1 → 0.2.3
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 +13 -3
- package/dist/index.js +205 -113
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# mergestorm-mcp
|
|
2
2
|
|
|
3
|
-
Stdio MCP server for Mergestorm. Tools: `whoami`, `credits`, `review_list`, `review_submit`, `review_wait`, `review_wait_pr`, `stack_adopt`, `stack_list`, `stack_set`, `stack_status`, `stack_wait`, `queue_status`, `settings_get`, `settings_set`.
|
|
3
|
+
Stdio MCP server for Mergestorm. Tools: `whoami`, `credits`, `review_list`, `review_submit`, `review_wait`, `review_wait_pr`, `review_dismiss`, `stack_adopt`, `stack_list`, `stack_set`, `stack_status`, `stack_wait`, `queue_status`, `settings_get`, `settings_set`.
|
|
4
4
|
|
|
5
|
-
Every tool declares MCP `annotations`. The reads carry `readOnlyHint: true`; `review_submit`, `stack_adopt`, `stack_set`, and `settings_set` carry `readOnlyHint: false` and `destructiveHint: false`.
|
|
5
|
+
Every tool declares MCP `annotations`. The reads carry `readOnlyHint: true`; `review_submit`, `review_dismiss`, `stack_adopt`, `stack_set`, and `settings_set` carry `readOnlyHint: false` and `destructiveHint: false`.
|
|
6
6
|
|
|
7
7
|
`review_wait({ job_id, timeout_s? })` reads a local review job by the `job_id` that `review_submit` returned. `review_wait_pr({ owner, repo, pr_number, after_sha?, pass?, after_pass?, timeout_s? })` reads the Vortex pass on a GitHub PR. Both poll one 45s slice by default; `timeout_s: 0` reads once without polling.
|
|
8
8
|
|
|
9
|
+
`review_dismiss({ owner, repo, pr_number, head_sha, review_id, finding_ids?, scope?, reason?, evidence_url?, preview? })` records an audited dismissal of Vortex findings the agent verified are wrong. The rules:
|
|
10
|
+
|
|
11
|
+
- **Exact identity.** `head_sha` is the PR's live head and `review_id` is a Vortex review (Core or seam) made at that head. `finding_ids` are that review's GitHub review comment ids (`offdiff-<n>` for a finding listed only in the review body, `review` for a body-only review). `scope: "review"` with no `finding_ids` covers every finding of the review.
|
|
12
|
+
- **Preview first.** `preview: true` lists the review's finding ids and the seam gate, and writes nothing.
|
|
13
|
+
- **Authorization.** The API key's account must monitor the repository or own the PR's stack, and its linked GitHub account needs write access to the repository.
|
|
14
|
+
- **Refusals.** A moved head (`stale_head`), another review (`review_mismatch`, `review_not_found`), an id outside that review (`unknown_finding`), a thin `reason` (`invalid_input`) or a missing permission (`forbidden`) is refused, and nothing is written.
|
|
15
|
+
- **Audit and retries.** Each finding is stored with the actor's GitHub login, the reason, `evidence_url`, the scope and the time. A retry reports the earlier record under `already_dismissed`.
|
|
16
|
+
- **Gates.** `gate.seam.cleared` is true only when every finding of that integration review is dismissed and it is still the member's current seam verdict. CI, other reviews and Auto land policy are unchanged (`gate.other_gates: "unchanged"`). For a stack PR the result carries `watch` with the next `stack_wait` call.
|
|
17
|
+
- **No resurfacing.** Vortex does not raise a dismissed finding again on the same diff: the same head, or a restack whose diff is unchanged.
|
|
18
|
+
|
|
9
19
|
The stack tools expose reads plus one scoped policy write:
|
|
10
20
|
|
|
11
21
|
- `stack_adopt({ owner, repo, pr_number, auto_land?, auto_review?, auto_patch? })` adopts an open PR chain with optional per-stack policy. Omitted policy does not force Cyclone off. `auto_review` and `auto_patch` accept `true`, `false`, or `null`; `null` clears the override. Read `stack_status` with the returned `result.stack.id` to verify policy and Cyclone ownership.
|
|
12
22
|
- `stack_list` returns the current API key owner's registered stacks.
|
|
13
23
|
- `stack_set({ stack_id, auto_land?, auto_review?, auto_patch? })` sets per-stack policy on one owned stack. `auto_land` flips Auto land. `auto_review` and `auto_patch` pin Vortex auto-review or Cyclone auto-patch for that stack in either direction (`true` or `false`); `null` clears the pin so the stack follows the account setting. Pass at least one key. Account settings are never changed here.
|
|
14
24
|
- `stack_status({ stack_id })` returns one owned stack with enriched checks and agent state, plus `attention`, `issues`, and `currentCandidate` from the same blocker rules as `stack_wait`, read against that stack's own merge queue. It returns a structured `stack_not_found` or `rate_limited` error.
|
|
15
|
-
- `stack_wait({ stack_id, timeout_s?, enrolled_head_sha?, after_finished_at?, bounce_id? })` waits for a stack or queue state change and returns the latest snapshots. `timeout_s: 0` returns one snapshot; a positive timeout after a snapshot with no attention returns the current `waiting` or `in_progress` status with selectors to use for the next call. An unread deadline returns `failed`. While Cyclone or Vortex is still working on the blocked PR, `stack_wait` and `stack_status` return `in_progress` instead of `attention`, with `busy[]` naming the PR, blocker, and agent, and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and busy flags. A nonempty `busy[]` means wait. A queued Vortex run holds for at most 15 minutes, `seam_pending` alone does not hold, and reviewing or patching holds until the run finishes or its lease or check run is cleared. A
|
|
25
|
+
- `stack_wait({ stack_id, timeout_s?, enrolled_head_sha?, after_finished_at?, bounce_id? })` waits for a stack or queue state change and returns the latest snapshots. `timeout_s: 0` returns one snapshot; a positive timeout after a snapshot with no attention returns the current `waiting` or `in_progress` status with selectors to use for the next call. An unread deadline returns `failed`. While Cyclone or Vortex is still working on the blocked PR, `stack_wait` and `stack_status` return `in_progress` instead of `attention`, with `busy[]` naming the PR, blocker, and agent, and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and busy flags. A nonempty `busy[]` means wait. A queued Vortex run holds for at most 15 minutes, `seam_pending` alone does not hold, and reviewing or patching holds until the run finishes or its lease or check run is cleared. A blocker no agent run clears (a merge conflict vs the live parent, a restack `Conflict` or `Restack failed`, a Draft PR, a seam review pending with no review running) is still named during that `in_progress`, in `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"` and `waitingOn` listing the busy agents; act when a snapshot after the agents finish returns `attention`, re-reading the blocker then. `repair` is included when a concrete repair hint is available. Blockers the agents can change keep `blocker` and `actAfter` null. If a hold looks stuck, refresh `stack_status` and tell the human.
|
|
16
26
|
|
|
17
27
|
Auth is `MERGESTORM_API_KEY`, then `~/.mergestorm/config.json` (same as `mg`).
|
|
18
28
|
|
package/dist/index.js
CHANGED
|
@@ -162,6 +162,153 @@ async function queueStatus(stackId, cfg) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// src/tools/review-dismiss.ts
|
|
166
|
+
import {
|
|
167
|
+
dismissPrFindings,
|
|
168
|
+
stackWatchObligation as stackWatchObligation2
|
|
169
|
+
} from "mergestorm/client";
|
|
170
|
+
|
|
171
|
+
// src/tools/stack-wait.ts
|
|
172
|
+
import {
|
|
173
|
+
loadConfig as loadConfig3,
|
|
174
|
+
stackBlockersSummary,
|
|
175
|
+
pollStackWatch,
|
|
176
|
+
StackWatchError,
|
|
177
|
+
StackWatchTimeoutError,
|
|
178
|
+
stackWatchObligation
|
|
179
|
+
} from "mergestorm/client";
|
|
180
|
+
import { z } from "zod";
|
|
181
|
+
var stackWaitSchema = {
|
|
182
|
+
stack_id: z.string().trim().min(1),
|
|
183
|
+
timeout_s: z.number().finite().min(0).max(45).default(45),
|
|
184
|
+
enrolled_head_sha: z.string().nullable().optional(),
|
|
185
|
+
after_finished_at: z.string().nullable().optional(),
|
|
186
|
+
bounce_id: z.string().nullable().optional()
|
|
187
|
+
};
|
|
188
|
+
function repairSummary(repair) {
|
|
189
|
+
if (!repair) return "";
|
|
190
|
+
const files = "files" in repair && repair.files.length ? ` Files: ${repair.files.join(", ")}.` : "";
|
|
191
|
+
return `
|
|
192
|
+
Repair #${repair.prNumber} (${repair.kind}).${files} ${repair.steps}`;
|
|
193
|
+
}
|
|
194
|
+
function withWatchText(watch, body) {
|
|
195
|
+
return `${watch.message}
|
|
196
|
+
|
|
197
|
+
${body}`;
|
|
198
|
+
}
|
|
199
|
+
function envelopeWatch(envelope) {
|
|
200
|
+
return envelope.watch ?? stackWatchObligation({
|
|
201
|
+
stackId: envelope.stackId,
|
|
202
|
+
terminal: null,
|
|
203
|
+
cursor: envelope.cursor,
|
|
204
|
+
status: envelope.status
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function stackWaitSummary(envelope) {
|
|
208
|
+
return withWatchText(envelopeWatch(envelope), `${stackWaitStatusLine(envelope)}${repairSummary(envelope.repair)}`);
|
|
209
|
+
}
|
|
210
|
+
function stackWaitStatusLine(envelope) {
|
|
211
|
+
const base = `Stack ${envelope.stackId} \xB7 ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending, envelope.actAfter ?? null)}${envelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`;
|
|
212
|
+
if (envelopeWatch(envelope).done || envelope.status !== "waiting" && envelope.status !== "in_progress") return base;
|
|
213
|
+
const selectors = [
|
|
214
|
+
`stack_id ${JSON.stringify(envelope.cursor.stackId)}`,
|
|
215
|
+
`enrolled_head_sha ${JSON.stringify(envelope.cursor.enrolledHeadSha)}`,
|
|
216
|
+
...envelope.cursor.afterFinishedAt !== void 0 ? [`after_finished_at ${JSON.stringify(envelope.cursor.afterFinishedAt)}`] : [],
|
|
217
|
+
...envelope.cursor.bounceId !== void 0 ? [`bounce_id ${JSON.stringify(envelope.cursor.bounceId)}`] : []
|
|
218
|
+
];
|
|
219
|
+
return `${base} \xB7 watch not finished: call stack_wait again with timeout_s 45 and the same cursor: ${selectors.join(", ")}`;
|
|
220
|
+
}
|
|
221
|
+
async function stackWait(input, cfg, deps = {}) {
|
|
222
|
+
const args = z.object(stackWaitSchema).parse(input);
|
|
223
|
+
const cursor = args.enrolled_head_sha !== void 0 || args.after_finished_at !== void 0 || args.bounce_id !== void 0 ? {
|
|
224
|
+
stackId: args.stack_id,
|
|
225
|
+
enrolledHeadSha: args.enrolled_head_sha ?? null,
|
|
226
|
+
...args.after_finished_at !== void 0 ? { afterFinishedAt: args.after_finished_at } : {},
|
|
227
|
+
...args.bounce_id !== void 0 ? { bounceId: args.bounce_id } : {}
|
|
228
|
+
} : void 0;
|
|
229
|
+
let envelope;
|
|
230
|
+
try {
|
|
231
|
+
envelope = await (deps.pollStackWatch ?? pollStackWatch)(cfg ?? await loadConfig3(), args.stack_id, {
|
|
232
|
+
timeoutMs: args.timeout_s * 1e3,
|
|
233
|
+
cursor,
|
|
234
|
+
signal: deps.signal
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err instanceof StackWatchError) {
|
|
238
|
+
const envelope2 = { ...err.lastEnvelope, watch: envelopeWatch(err.lastEnvelope) };
|
|
239
|
+
return {
|
|
240
|
+
summary: stackWaitSummary(envelope2),
|
|
241
|
+
data: {
|
|
242
|
+
...envelope2,
|
|
243
|
+
...err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
|
|
244
|
+
},
|
|
245
|
+
isError: !envelope2.watch.done
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (!(err instanceof StackWatchTimeoutError)) throw err;
|
|
249
|
+
envelope = err.lastEnvelope;
|
|
250
|
+
}
|
|
251
|
+
const sealed = { ...envelope, watch: envelopeWatch(envelope) };
|
|
252
|
+
return {
|
|
253
|
+
summary: stackWaitSummary(sealed),
|
|
254
|
+
data: sealed
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/tools/review-dismiss.ts
|
|
259
|
+
function findingText(finding) {
|
|
260
|
+
return `${finding.finding_id} (${finding.path ?? "review body"}: ${finding.title})`;
|
|
261
|
+
}
|
|
262
|
+
function reviewDismissSummary(result) {
|
|
263
|
+
const pr = `${result.owner}/${result.repo}#${result.pr_number}`;
|
|
264
|
+
const kind = result.review_kind === "seam" ? "seam review" : "Vortex review";
|
|
265
|
+
const head = `${kind} ${result.review_id} at ${result.head_sha.slice(0, 7)}`;
|
|
266
|
+
const lines = [];
|
|
267
|
+
if (result.status === "preview") {
|
|
268
|
+
lines.push(`${pr} ${head}: ${result.remaining.length} open finding(s). Nothing was written.`);
|
|
269
|
+
for (const finding of result.remaining) lines.push(`- ${findingText(finding)}`);
|
|
270
|
+
} else {
|
|
271
|
+
lines.push(
|
|
272
|
+
`${pr} ${head}: dismissed ${result.dismissed.length}, already dismissed ${result.already_dismissed.length}, still open ${result.remaining.length}.`
|
|
273
|
+
);
|
|
274
|
+
for (const finding of result.remaining) lines.push(`- open: ${findingText(finding)}`);
|
|
275
|
+
}
|
|
276
|
+
const seam = result.gate.seam;
|
|
277
|
+
if (seam) {
|
|
278
|
+
lines.push(
|
|
279
|
+
seam.cleared ? "Seam gate cleared at this head. CI, other reviews and Auto land policy are unchanged." : seam.blocking ? `Seam gate still blocking (seam_state=${seam.state}).` : `Seam gate not blocking (seam_state=${seam.state}).`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
async function reviewDismiss(input, cfg) {
|
|
285
|
+
const request = {
|
|
286
|
+
owner: input.owner,
|
|
287
|
+
repo: input.repo,
|
|
288
|
+
prNumber: input.pr_number,
|
|
289
|
+
headSha: input.head_sha,
|
|
290
|
+
reviewId: input.review_id,
|
|
291
|
+
...input.scope ? { scope: input.scope } : {},
|
|
292
|
+
...input.finding_ids ? { findingIds: input.finding_ids } : {},
|
|
293
|
+
...input.reason !== void 0 ? { reason: input.reason } : {},
|
|
294
|
+
...input.evidence_url ? { evidenceUrl: input.evidence_url } : {},
|
|
295
|
+
...input.preview ? { preview: true } : {}
|
|
296
|
+
};
|
|
297
|
+
const outcome = await dismissPrFindings(request, cfg);
|
|
298
|
+
if (!outcome.ok) {
|
|
299
|
+
return {
|
|
300
|
+
summary: `review_dismiss refused (${outcome.error}): ${outcome.message}`,
|
|
301
|
+
isError: true,
|
|
302
|
+
data: { error: { code: outcome.error, message: outcome.message, status: outcome.status } }
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const result = outcome.result;
|
|
306
|
+
const summary = reviewDismissSummary(result);
|
|
307
|
+
if (!result.stack_id) return { summary, data: { ...result } };
|
|
308
|
+
const watch = stackWatchObligation2({ stackId: result.stack_id, terminal: null, unread: true, freshCursor: true });
|
|
309
|
+
return { summary: withWatchText(watch, summary), data: { ...result, watch } };
|
|
310
|
+
}
|
|
311
|
+
|
|
165
312
|
// src/tools/review-list.ts
|
|
166
313
|
import { listJobs, toReviewJobEnvelope } from "mergestorm/client";
|
|
167
314
|
async function reviewList(limit = 10, cfg) {
|
|
@@ -185,7 +332,7 @@ import {
|
|
|
185
332
|
collectReviewInput,
|
|
186
333
|
discoverTrunk,
|
|
187
334
|
formatReviewSubmitError,
|
|
188
|
-
loadConfig as
|
|
335
|
+
loadConfig as loadConfig4,
|
|
189
336
|
loadReviewContext,
|
|
190
337
|
pollReview,
|
|
191
338
|
rateLimitedMessage as rateLimitedMessage2,
|
|
@@ -289,7 +436,7 @@ async function resolveInput(input) {
|
|
|
289
436
|
return collectReviewInput(base, head, cwd, sandboxRoot);
|
|
290
437
|
}
|
|
291
438
|
async function reviewSubmit(input, cfg, opts = {}) {
|
|
292
|
-
const resolved = cfg ?? await
|
|
439
|
+
const resolved = cfg ?? await loadConfig4();
|
|
293
440
|
const wait = input.wait === true;
|
|
294
441
|
const timeoutMs = waitTimeoutMs(input.timeout_s);
|
|
295
442
|
assertPinnableSpecialists(input.specialists);
|
|
@@ -376,7 +523,7 @@ async function reviewSubmit(input, cfg, opts = {}) {
|
|
|
376
523
|
// src/tools/review-wait-pr.ts
|
|
377
524
|
import {
|
|
378
525
|
PrReviewPollTimeoutError,
|
|
379
|
-
loadConfig as
|
|
526
|
+
loadConfig as loadConfig5,
|
|
380
527
|
pollPrVortexReview
|
|
381
528
|
} from "mergestorm/client";
|
|
382
529
|
|
|
@@ -458,7 +605,7 @@ async function reviewGetPr(owner, repo, prNumber, cfg, opts = {}) {
|
|
|
458
605
|
// src/tools/review-wait-pr.ts
|
|
459
606
|
async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opts = {}) {
|
|
460
607
|
const target = prReviewTarget(owner, repo, prNumber);
|
|
461
|
-
const resolved = cfg ?? await
|
|
608
|
+
const resolved = cfg ?? await loadConfig5();
|
|
462
609
|
if (timeout_s === 0) {
|
|
463
610
|
return reviewGetPr(target.owner, target.repo, target.prNumber, resolved, {
|
|
464
611
|
afterSha,
|
|
@@ -527,7 +674,7 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
|
|
|
527
674
|
import {
|
|
528
675
|
CommandError as CommandError6,
|
|
529
676
|
ReviewPollTimeoutError as ReviewPollTimeoutError2,
|
|
530
|
-
loadConfig as
|
|
677
|
+
loadConfig as loadConfig6,
|
|
531
678
|
pollReview as pollReview2
|
|
532
679
|
} from "mergestorm/client";
|
|
533
680
|
|
|
@@ -560,7 +707,7 @@ async function reviewGet(jobId, cfg) {
|
|
|
560
707
|
|
|
561
708
|
// src/tools/review-wait.ts
|
|
562
709
|
async function reviewWait(jobId, timeout_s, cfg, opts = {}) {
|
|
563
|
-
const resolved = cfg ?? await
|
|
710
|
+
const resolved = cfg ?? await loadConfig6();
|
|
564
711
|
const id = jobId.trim();
|
|
565
712
|
if (!id) {
|
|
566
713
|
throw new CommandError6("job_id is required", 2, "usage");
|
|
@@ -672,100 +819,11 @@ async function settingsSet(args, cfg) {
|
|
|
672
819
|
}
|
|
673
820
|
|
|
674
821
|
// src/tools/stack-adopt.ts
|
|
675
|
-
import { adoptStack, CommandError as CommandError9, stackWatchObligation as
|
|
822
|
+
import { adoptStack, CommandError as CommandError9, stackWatchObligation as stackWatchObligation4 } from "mergestorm/client";
|
|
676
823
|
import { z as z2 } from "zod";
|
|
677
824
|
|
|
678
825
|
// src/tools/stack-set.ts
|
|
679
|
-
import { setStackPolicy, stackWatchObligation as
|
|
680
|
-
|
|
681
|
-
// src/tools/stack-wait.ts
|
|
682
|
-
import {
|
|
683
|
-
loadConfig as loadConfig6,
|
|
684
|
-
stackBlockersSummary,
|
|
685
|
-
pollStackWatch,
|
|
686
|
-
StackWatchError,
|
|
687
|
-
StackWatchTimeoutError,
|
|
688
|
-
stackWatchObligation
|
|
689
|
-
} from "mergestorm/client";
|
|
690
|
-
import { z } from "zod";
|
|
691
|
-
var stackWaitSchema = {
|
|
692
|
-
stack_id: z.string().trim().min(1),
|
|
693
|
-
timeout_s: z.number().finite().min(0).max(45).default(45),
|
|
694
|
-
enrolled_head_sha: z.string().nullable().optional(),
|
|
695
|
-
after_finished_at: z.string().nullable().optional(),
|
|
696
|
-
bounce_id: z.string().nullable().optional()
|
|
697
|
-
};
|
|
698
|
-
function repairSummary(repair) {
|
|
699
|
-
if (!repair) return "";
|
|
700
|
-
const files = "files" in repair && repair.files.length ? ` Files: ${repair.files.join(", ")}.` : "";
|
|
701
|
-
return `
|
|
702
|
-
Repair #${repair.prNumber} (${repair.kind}).${files} ${repair.steps}`;
|
|
703
|
-
}
|
|
704
|
-
function withWatchText(watch, body) {
|
|
705
|
-
return `${watch.message}
|
|
706
|
-
|
|
707
|
-
${body}`;
|
|
708
|
-
}
|
|
709
|
-
function envelopeWatch(envelope) {
|
|
710
|
-
return envelope.watch ?? stackWatchObligation({
|
|
711
|
-
stackId: envelope.stackId,
|
|
712
|
-
terminal: null,
|
|
713
|
-
cursor: envelope.cursor,
|
|
714
|
-
status: envelope.status
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
function stackWaitSummary(envelope) {
|
|
718
|
-
return withWatchText(envelopeWatch(envelope), `${stackWaitStatusLine(envelope)}${repairSummary(envelope.repair)}`);
|
|
719
|
-
}
|
|
720
|
-
function stackWaitStatusLine(envelope) {
|
|
721
|
-
const base = `Stack ${envelope.stackId} \xB7 ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending)}${envelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`;
|
|
722
|
-
if (envelopeWatch(envelope).done || envelope.status !== "waiting" && envelope.status !== "in_progress") return base;
|
|
723
|
-
const selectors = [
|
|
724
|
-
`stack_id ${JSON.stringify(envelope.cursor.stackId)}`,
|
|
725
|
-
`enrolled_head_sha ${JSON.stringify(envelope.cursor.enrolledHeadSha)}`,
|
|
726
|
-
...envelope.cursor.afterFinishedAt !== void 0 ? [`after_finished_at ${JSON.stringify(envelope.cursor.afterFinishedAt)}`] : [],
|
|
727
|
-
...envelope.cursor.bounceId !== void 0 ? [`bounce_id ${JSON.stringify(envelope.cursor.bounceId)}`] : []
|
|
728
|
-
];
|
|
729
|
-
return `${base} \xB7 watch not finished: call stack_wait again with timeout_s 45 and the same cursor: ${selectors.join(", ")}`;
|
|
730
|
-
}
|
|
731
|
-
async function stackWait(input, cfg, deps = {}) {
|
|
732
|
-
const args = z.object(stackWaitSchema).parse(input);
|
|
733
|
-
const cursor = args.enrolled_head_sha !== void 0 || args.after_finished_at !== void 0 || args.bounce_id !== void 0 ? {
|
|
734
|
-
stackId: args.stack_id,
|
|
735
|
-
enrolledHeadSha: args.enrolled_head_sha ?? null,
|
|
736
|
-
...args.after_finished_at !== void 0 ? { afterFinishedAt: args.after_finished_at } : {},
|
|
737
|
-
...args.bounce_id !== void 0 ? { bounceId: args.bounce_id } : {}
|
|
738
|
-
} : void 0;
|
|
739
|
-
let envelope;
|
|
740
|
-
try {
|
|
741
|
-
envelope = await (deps.pollStackWatch ?? pollStackWatch)(cfg ?? await loadConfig6(), args.stack_id, {
|
|
742
|
-
timeoutMs: args.timeout_s * 1e3,
|
|
743
|
-
cursor,
|
|
744
|
-
signal: deps.signal
|
|
745
|
-
});
|
|
746
|
-
} catch (err) {
|
|
747
|
-
if (err instanceof StackWatchError) {
|
|
748
|
-
const envelope2 = { ...err.lastEnvelope, watch: envelopeWatch(err.lastEnvelope) };
|
|
749
|
-
return {
|
|
750
|
-
summary: stackWaitSummary(envelope2),
|
|
751
|
-
data: {
|
|
752
|
-
...envelope2,
|
|
753
|
-
...err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
|
|
754
|
-
},
|
|
755
|
-
isError: !envelope2.watch.done
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
if (!(err instanceof StackWatchTimeoutError)) throw err;
|
|
759
|
-
envelope = err.lastEnvelope;
|
|
760
|
-
}
|
|
761
|
-
const sealed = { ...envelope, watch: envelopeWatch(envelope) };
|
|
762
|
-
return {
|
|
763
|
-
summary: stackWaitSummary(sealed),
|
|
764
|
-
data: sealed
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// src/tools/stack-set.ts
|
|
826
|
+
import { setStackPolicy, stackWatchObligation as stackWatchObligation3 } from "mergestorm/client";
|
|
769
827
|
function triWord(value) {
|
|
770
828
|
return value === null ? "default" : value ? "on" : "off";
|
|
771
829
|
}
|
|
@@ -802,7 +860,7 @@ async function stackSet(stackId, input, cfg) {
|
|
|
802
860
|
if (input.auto_patch !== void 0) {
|
|
803
861
|
parts.push(`auto-patch ${triWord(input.auto_patch)}`);
|
|
804
862
|
}
|
|
805
|
-
const watch =
|
|
863
|
+
const watch = stackWatchObligation3({ stackId, terminal: null, unread: true, freshCursor: true });
|
|
806
864
|
return {
|
|
807
865
|
summary: withWatchText(watch, `${parts.join(", ")} for stack ${stackId}`),
|
|
808
866
|
data: {
|
|
@@ -846,7 +904,7 @@ async function stackAdopt(input, cfg) {
|
|
|
846
904
|
if (typeof stackId !== "string" || !stackId) {
|
|
847
905
|
return { summary, data: { owner, repo, pr_number, result } };
|
|
848
906
|
}
|
|
849
|
-
const watch =
|
|
907
|
+
const watch = stackWatchObligation4({ stackId, terminal: null, unread: true, freshCursor: true });
|
|
850
908
|
return {
|
|
851
909
|
summary: withWatchText(watch, summary),
|
|
852
910
|
data: { owner, repo, pr_number, result, watch }
|
|
@@ -891,8 +949,8 @@ function stackSummary(stack, entries = []) {
|
|
|
891
949
|
if (stack.cycloneOwnerMatch) {
|
|
892
950
|
overrides.push(`cyclone-owner ${stack.cycloneOwnerMatch}`);
|
|
893
951
|
}
|
|
894
|
-
const { attention, issues, busy, landGatePending } = stackBlockers(stack, entries);
|
|
895
|
-
return `${stack.owner}/${stack.repo} \xB7 ${layerLabel} \xB7 ${currentState} \xB7 auto-land ${stack.autoEnqueueWhenReady ? "on" : "off"}${overrides.map((label) => ` \xB7 ${label}`).join("")}${stackBlockersSummary2(attention, issues, busy, landGatePending)}`;
|
|
952
|
+
const { attention, held, issues, busy, landGatePending } = stackBlockers(stack, entries);
|
|
953
|
+
return `${stack.owner}/${stack.repo} \xB7 ${layerLabel} \xB7 ${currentState} \xB7 auto-land ${stack.autoEnqueueWhenReady ? "on" : "off"}${overrides.map((label) => ` \xB7 ${label}`).join("")}${stackBlockersSummary2(attention ?? held, issues, busy, landGatePending, held?.actAfter ?? null)}`;
|
|
896
954
|
}
|
|
897
955
|
|
|
898
956
|
// src/tools/stack-list.ts
|
|
@@ -929,14 +987,14 @@ import {
|
|
|
929
987
|
isCommandErrorCode as isCommandErrorCode5,
|
|
930
988
|
stackBlockers as stackBlockers2,
|
|
931
989
|
stackTerminalReason,
|
|
932
|
-
stackWatchObligation as
|
|
990
|
+
stackWatchObligation as stackWatchObligation5
|
|
933
991
|
} from "mergestorm/client";
|
|
934
992
|
async function stackStatus(stackId, cfg) {
|
|
935
993
|
try {
|
|
936
994
|
const stack = await getEnrichedStack(stackId, cfg);
|
|
937
995
|
if (!stack) {
|
|
938
996
|
const message = `Stack not found or not owned by the current user: ${stackId}`;
|
|
939
|
-
const watch2 =
|
|
997
|
+
const watch2 = stackWatchObligation5({ stackId, terminal: "not_found" });
|
|
940
998
|
return {
|
|
941
999
|
summary: withWatchText(watch2, message),
|
|
942
1000
|
data: {
|
|
@@ -951,8 +1009,8 @@ async function stackStatus(stackId, cfg) {
|
|
|
951
1009
|
};
|
|
952
1010
|
}
|
|
953
1011
|
const entries = await listMergeQueueEntries2(cfg, { stackId: stack.id });
|
|
954
|
-
const { attention, issues, currentCandidate, busy, agents, repair, landGatePending } = stackBlockers2(stack, entries);
|
|
955
|
-
const watch =
|
|
1012
|
+
const { attention, held, issues, currentCandidate, busy, agents, repair, landGatePending } = stackBlockers2(stack, entries);
|
|
1013
|
+
const watch = stackWatchObligation5({
|
|
956
1014
|
stackId: stack.id,
|
|
957
1015
|
terminal: stackTerminalReason(stack),
|
|
958
1016
|
cursor: { enrolledHeadSha: currentCandidate?.headSha ?? null },
|
|
@@ -961,11 +1019,11 @@ async function stackStatus(stackId, cfg) {
|
|
|
961
1019
|
});
|
|
962
1020
|
return {
|
|
963
1021
|
summary: withWatchText(watch, `${stackSummary(stack, entries)}${repairSummary(repair)}`),
|
|
964
|
-
data: { stack, attention, issues, currentCandidate, busy, agents, repair, landGatePending, watch }
|
|
1022
|
+
data: { stack, attention, held, issues, currentCandidate, busy, agents, repair, landGatePending, watch }
|
|
965
1023
|
};
|
|
966
1024
|
} catch (err) {
|
|
967
1025
|
if (isCommandErrorCode5(err, "rate_limited")) {
|
|
968
|
-
const watch =
|
|
1026
|
+
const watch = stackWatchObligation5({
|
|
969
1027
|
stackId,
|
|
970
1028
|
terminal: null,
|
|
971
1029
|
status: "rate_limited",
|
|
@@ -1036,19 +1094,22 @@ var MCP_PR_LOOP_INSTRUCTIONS = [
|
|
|
1036
1094
|
"- Prefer the smallest correct patch. Do not refactor around a finding.",
|
|
1037
1095
|
"- Patch concrete bugs. A chat-only explanation is not a dismiss.",
|
|
1038
1096
|
`- If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line must be: ${MCP_FINDING_DISMISS_MARKER}. Then one line per skipped finding (path, severity, why).`,
|
|
1039
|
-
"- Do not prefix that comment with cyclone-outcome: (reserved)."
|
|
1097
|
+
"- Do not prefix that comment with cyclone-outcome: (reserved).",
|
|
1098
|
+
"- A finding you verified is wrong can be dismissed on the record with review_dismiss (or mg dismiss): pass the PR's live head_sha, the review_id, the finding_ids (preview: true lists them) and a reason. That is the only agent path that clears a seam gate, and only once every finding of that seam review is dismissed. The dismiss comment alone resumes the review but does not clear the seam gate.",
|
|
1099
|
+
"- Never dismiss a finding you did not check, one a human must decide, or findings of a newer review."
|
|
1040
1100
|
].join("\n");
|
|
1041
1101
|
var MCP_STACK_DONE_INSTRUCTIONS = [
|
|
1042
1102
|
"When stack work is done:",
|
|
1043
1103
|
"- Stack work is done only when the stack is landed, or when the human takes it back (archives it or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it.",
|
|
1044
1104
|
"- Every stack_wait, stack_status, stack_adopt, and stack_set result carries watch: {done, until, reason, next, message}. While watch.done is false, keep calling stack_wait with watch.next.args (the returned cursor), 45s at a time, and do not end your task. Stop only when watch.done is true.",
|
|
1105
|
+
"- A layer PR shown as merged is merged into mg-stack-<n>, not main. The stack is landed only when the land PR (head mg-stack-<n>) merges into main or watch.done says landed. Never judge landing from GitHub PR state alone.",
|
|
1045
1106
|
"- attention means something for you to fix; repair, when present, names the concrete fix. A pending land gate (landGatePending: ci_pending, tempest_pending, tempest_running) is a wait, not attention."
|
|
1046
1107
|
].join("\n");
|
|
1047
1108
|
var MCP_STACK_BASE_INSTRUCTIONS = [
|
|
1048
1109
|
"Stack PR bases (Mergestorm sets these; leave them):",
|
|
1049
1110
|
"- Stack of 2+ PRs: after adopt (stack_adopt, mg stack adopt, or mg stack submit) the bottom PR's GitHub base is the owned trunk mg-stack-<n>, not main. That is correct. Layer 2 is based on layer 1; layer 3+ on the mg-park-* freeze; the git parent of layer 2+ is still the layer below. A 1-PR stack stays on main and gets no mg-stack-<n>.",
|
|
1050
1111
|
"- Never retarget a stack PR's GitHub base (gh pr edit --base, a REST base change, or the UI). Moving the bottom to main makes Mergestorm abandon the stack's review unit; setting mg-stack-<n> again does not restore it. If a base was already moved, stop and tell the human; do not run stack land or re-adopt to repair it.",
|
|
1051
|
-
"- mg-stack-<n> is the bottom PR's live parent: merging it into the bottom branch is that PR's conflict repair (bounce-watch rules). Never push to mg-stack-<n>, and
|
|
1112
|
+
"- mg-stack-<n> is the bottom PR's live parent: merging it into the bottom branch is that PR's conflict repair (bounce-watch rules). Never push to mg-stack-<n> except as the land PR's own repair: once every layer is promoted, the land PR's head is mg-stack-<n> and its live parent is its base (main), so merging main into mg-stack-<n>, a CI fix on it, or a fix for tempest_findings on it, with an ordinary push (no force) is that repair. Fix tempest_findings only when busy[] shows no Cyclone run on the land PR, and verify each finding first, as for Vortex findings. Never rebase or merge layer 2+ onto mg-stack-<n> or onto main. mg-park-* is never a merge or rebase target.",
|
|
1052
1113
|
"- mg stack submit prints the base each PR opened with; the bottom moves to mg-stack-<n> right after. stack_status trunkBranch is the current trunk."
|
|
1053
1114
|
].join("\n");
|
|
1054
1115
|
var MCP_STACK_WATCH_INSTRUCTIONS = [
|
|
@@ -1059,15 +1120,19 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
|
|
|
1059
1120
|
"- attention names the blocked PR via prNumber/headSha; currentCandidate is the promote candidate. Attention is not a mutation or authorization to change policy.",
|
|
1060
1121
|
"- verifyHeadSha is a queue verification SHA, not the live PR head.",
|
|
1061
1122
|
"- Do not patch a bounced PR while Cyclone or Vortex is still working on it. stack_wait and stack_status enforce this: they return in_progress instead of attention, with busy[] naming the PR, blocker, and agent, and agents carrying that PR's vortexStatus, cycloneStatus, vortexReview, and busy flags. in_progress with a nonempty busy[] means wait. Recheck the live remote PR head right before pushing.",
|
|
1062
|
-
"- seam_pending alone, and a queued Vortex run older than 15 minutes, do not hold. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared
|
|
1123
|
+
"- seam_pending alone, and a queued Vortex run older than 15 minutes, do not hold. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared; if it looks stuck, refresh stack_status and tell the human.",
|
|
1124
|
+
'- A blocker no Vortex or Cyclone run clears (a merge conflict vs the live parent, a restack Conflict or Restack failed, a Draft PR, a seam review pending with no review running, or a review skipped for review quota) stays in_progress during the hold but is named in blocker, prNumber, headSha, and issues[], with actAfter: "agents_idle" and waitingOn listing the busy agents. Plan the fix; act only when a snapshot after the agents finish returns attention, and re-read the blocker then. Blockers the agents can change keep blocker null and appear only in busy[].',
|
|
1063
1125
|
"- stack.autoEnqueueSettle with action ready or promote means Auto land found Cyclone and Vortex idle on that PR's green head and started its settle clock. action mergeability is published earlier, on a head that may not be green or idle yet.",
|
|
1064
1126
|
"- mg-park-* is a frozen GitHub base, not a repair target: never merge or rebase onto mg-park-*.",
|
|
1127
|
+
"- merge_failed, gh_error, and ci_timeout bounces turn Auto land off, and stack autoLandOff then reads reason bounced with that queue entry's id; any other autoLandOff reason (user, archive, watcher, cancelled, landed) is not a bounce to undo. The watching agent turns Auto land back on with stack_set auto_land: true once it has handled that bounce, at most once per bounce and 3 times per watch; mergestorm-bounce-watch has the conditions.",
|
|
1128
|
+
'- While stack autoLandOff reads reason bounced, the watch returns attention: blocker "Auto land off after <kind> bounce", the bounce in issues[], repair kind auto_land_off.',
|
|
1129
|
+
"- Tempest never patches. Cyclone patches a land PR when auto-patch is on for that stack (its override, else the account setting); otherwise the watching agent fixes tempest_findings on the land PR, and repair kind tempest_findings names it.",
|
|
1065
1130
|
"- Use the mergestorm-bounce-watch skill for the fix-then-watch policy."
|
|
1066
1131
|
].join("\n");
|
|
1067
1132
|
|
|
1068
1133
|
// src/server.ts
|
|
1069
1134
|
var MCP_SERVER_NAME = "mergestorm";
|
|
1070
|
-
var MCP_SERVER_VERSION = "0.2.
|
|
1135
|
+
var MCP_SERVER_VERSION = "0.2.3";
|
|
1071
1136
|
var MCP_SERVER_INSTRUCTIONS = [
|
|
1072
1137
|
MCP_STACK_DONE_INSTRUCTIONS,
|
|
1073
1138
|
MCP_PR_LOOP_INSTRUCTIONS,
|
|
@@ -1244,6 +1309,33 @@ function createMergestormMcpServer() {
|
|
|
1244
1309
|
}
|
|
1245
1310
|
}
|
|
1246
1311
|
);
|
|
1312
|
+
addTool(
|
|
1313
|
+
"review_dismiss",
|
|
1314
|
+
{
|
|
1315
|
+
title: "Dismiss Vortex findings",
|
|
1316
|
+
description: `Record an audited dismissal of Vortex findings you verified are wrong or not actionable on a GitHub PR. Identity is exact: head_sha must be the PR's live head, review_id a Vortex review (Core or seam) made at that head, and finding_ids the GitHub review comment ids of that review (offdiff-<n> for a body-only finding), or scope "review" with no finding_ids to dismiss every finding of that review. reason must say why (at least 20 characters); evidence_url is optional. Call with preview: true first to list the review's finding ids and the seam gate without writing. The caller's linked GitHub account needs write access to the repository. A moved head, another review, an unknown id or a missing permission is refused and nothing is written. Retries are idempotent. The seam gate clears only when every finding of that integration review is dismissed; CI, other reviews and Auto land policy are unchanged. Vortex does not raise a dismissed finding again on the same diff. Never dismiss a finding you did not check.` + STACK_WATCH_RESULT_NOTE,
|
|
1317
|
+
inputSchema: {
|
|
1318
|
+
owner: z3.string().min(1),
|
|
1319
|
+
repo: z3.string().min(1),
|
|
1320
|
+
pr_number: z3.number().int().min(1),
|
|
1321
|
+
head_sha: z3.string().regex(/^[0-9a-fA-F]{40}$/),
|
|
1322
|
+
review_id: z3.number().int().min(1),
|
|
1323
|
+
finding_ids: z3.array(z3.string().min(1)).max(50).optional(),
|
|
1324
|
+
scope: z3.enum(["findings", "review"]).optional(),
|
|
1325
|
+
reason: z3.string().optional(),
|
|
1326
|
+
evidence_url: z3.string().url().optional(),
|
|
1327
|
+
preview: z3.boolean().optional()
|
|
1328
|
+
},
|
|
1329
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1330
|
+
},
|
|
1331
|
+
async (args) => {
|
|
1332
|
+
try {
|
|
1333
|
+
return ok(await reviewDismiss(args));
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
return fail(err);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
);
|
|
1247
1339
|
addTool(
|
|
1248
1340
|
"stack_adopt",
|
|
1249
1341
|
{
|
|
@@ -1306,7 +1398,7 @@ function createMergestormMcpServer() {
|
|
|
1306
1398
|
"stack_status",
|
|
1307
1399
|
{
|
|
1308
1400
|
title: "Get stack status",
|
|
1309
|
-
description:
|
|
1401
|
+
description: `Fetch one owned Mergestorm stack with enriched checks and agent state, plus attention, issues, and currentCandidate from the same blocker rules as stack_wait, read against that stack's own merge queue. trunkBranch is mg-stack-<n> for an adopted 2+ PR stack and matches the bottom open PR's GitHub base; an mg-park-* parentBranch is a freeze. Neither is drift to fix. repair names the concrete fix for attention; landGatePending is a wait, not attention. held names a blocker no agent run clears while Vortex or Cyclone is still busy on that PR (actAfter agents_idle, waitingOn); repair then describes the fix to plan, not to apply yet. Auto land off after a merge_failed, gh_error, or ci_timeout bounce (the stack's autoLandOff reads reason bounced) returns attention with the blocker "Auto land off after <kind> bounce", the bounce in issues[], and a repair of kind auto_land_off: fix the cause, then turn Auto land back on or enqueue.` + STACK_WATCH_RESULT_NOTE,
|
|
1310
1402
|
inputSchema: {
|
|
1311
1403
|
stack_id: z3.string().min(1)
|
|
1312
1404
|
},
|
|
@@ -1324,7 +1416,7 @@ function createMergestormMcpServer() {
|
|
|
1324
1416
|
"stack_wait",
|
|
1325
1417
|
{
|
|
1326
1418
|
title: "Wait for stack attention",
|
|
1327
|
-
description:
|
|
1419
|
+
description: `Wait for a stack to need attention. Returns a mergestorm.stack_watch/v1 envelope; timeout_s: 0 returns one snapshot and timeout_s: 1-45 waits up to that many seconds, returning the current status (waiting or in_progress) if a snapshot was read and no attention is found. A timeout with either of those statuses is not a failure; waiting and in_progress end this slice, and the next call is stack_wait again with the same cursor. A timeout of failed means no assessment was produced. A stack that no longer exists returns status failed with watch.done true (reason not_found) and is not an error. While Cyclone or Vortex is still working on the blocked PR it returns in_progress, not attention, with busy[] naming the PR, blocker, and agent; agents carries that PR's vortexStatus, cycloneStatus, vortexReview, and busy flags. A blocker no agent run clears (a merge conflict vs the live parent, a restack Conflict or Restack failed, a Draft PR, a seam review pending with no review running, or a review skipped for review quota) is still named during that in_progress, in blocker, prNumber, headSha, and issues[], with actAfter: "agents_idle" and waitingOn listing the busy agents: plan the fix, and act when a snapshot after the agents finish returns attention, re-reading the blocker then. Blockers the agents can change keep blocker and actAfter null. Attention never calls for changing a PR's GitHub base. When present, repair names the concrete fix for attention (restack_conflict and merge_conflict carry liveParent, never mg-park-*). A pending land gate (landGatePending) returns in_progress, not attention. Auto land off after a merge_failed, gh_error, or ci_timeout bounce (the stack's autoLandOff reads reason bounced) returns attention with the blocker "Auto land off after <kind> bounce", the bounce in issues[], and a repair of kind auto_land_off: fix the cause, then turn Auto land back on or enqueue.` + STACK_WATCH_RESULT_NOTE,
|
|
1328
1420
|
inputSchema: stackWaitSchema,
|
|
1329
1421
|
annotations: { readOnlyHint: true }
|
|
1330
1422
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mergestorm-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Mergestorm MCP server — identity, review, and read-only stack tools over stdio",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
|
-
"mergestorm": "0.3.
|
|
32
|
+
"mergestorm": "0.3.28",
|
|
33
33
|
"zod": "^3.24.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|