mergestorm-mcp 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/dist/index.js +257 -106
  3. 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 hold can hide a blocker no agent clears (such as Draft); if it looks stuck, refresh `stack_status` and tell the human.
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) 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 loadConfig3,
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 loadConfig3();
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 loadConfig4,
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 loadConfig4();
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 loadConfig5,
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 loadConfig5();
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,11 +819,11 @@ async function settingsSet(args, cfg) {
672
819
  }
673
820
 
674
821
  // src/tools/stack-adopt.ts
675
- import { adoptStack, CommandError as CommandError9 } from "mergestorm/client";
676
- import { z } from "zod";
822
+ import { adoptStack, CommandError as CommandError9, stackWatchObligation as stackWatchObligation4 } from "mergestorm/client";
823
+ import { z as z2 } from "zod";
677
824
 
678
825
  // src/tools/stack-set.ts
679
- import { setStackPolicy } from "mergestorm/client";
826
+ import { setStackPolicy, stackWatchObligation as stackWatchObligation3 } from "mergestorm/client";
680
827
  function triWord(value) {
681
828
  return value === null ? "default" : value ? "on" : "off";
682
829
  }
@@ -713,28 +860,30 @@ async function stackSet(stackId, input, cfg) {
713
860
  if (input.auto_patch !== void 0) {
714
861
  parts.push(`auto-patch ${triWord(input.auto_patch)}`);
715
862
  }
863
+ const watch = stackWatchObligation3({ stackId, terminal: null, unread: true, freshCursor: true });
716
864
  return {
717
- summary: `${parts.join(", ")} for stack ${stackId}`,
865
+ summary: withWatchText(watch, `${parts.join(", ")} for stack ${stackId}`),
718
866
  data: {
719
867
  stack_id: stackId,
720
868
  ...typeof input.auto_land === "boolean" ? { auto_land: input.auto_land } : {},
721
869
  ...input.auto_review !== void 0 ? { auto_review: input.auto_review } : {},
722
870
  ...input.auto_patch !== void 0 ? { auto_patch: input.auto_patch } : {},
723
- result
871
+ result,
872
+ watch
724
873
  }
725
874
  };
726
875
  }
727
876
 
728
877
  // src/tools/stack-adopt.ts
729
878
  var stackAdoptSchema = {
730
- owner: z.string().trim().min(1),
731
- repo: z.string().trim().min(1),
732
- pr_number: z.number().int().min(1),
733
- auto_land: z.boolean().optional(),
734
- auto_review: z.boolean().nullable().optional(),
735
- auto_patch: z.boolean().nullable().optional()
879
+ owner: z2.string().trim().min(1),
880
+ repo: z2.string().trim().min(1),
881
+ pr_number: z2.number().int().min(1),
882
+ auto_land: z2.boolean().optional(),
883
+ auto_review: z2.boolean().nullable().optional(),
884
+ auto_patch: z2.boolean().nullable().optional()
736
885
  };
737
- var inputSchema = z.object(stackAdoptSchema);
886
+ var inputSchema = z2.object(stackAdoptSchema);
738
887
  async function stackAdopt(input, cfg) {
739
888
  const parsed = inputSchema.safeParse(input);
740
889
  if (!parsed.success) {
@@ -750,9 +899,15 @@ async function stackAdopt(input, cfg) {
750
899
  cfg,
751
900
  stackSetPatch(parsed.data)
752
901
  );
902
+ const summary = `Adopted stack for ${owner}/${repo}#${pr_number}`;
903
+ const stackId = typeof result === "object" && result !== null ? result.stack?.id : void 0;
904
+ if (typeof stackId !== "string" || !stackId) {
905
+ return { summary, data: { owner, repo, pr_number, result } };
906
+ }
907
+ const watch = stackWatchObligation4({ stackId, terminal: null, unread: true, freshCursor: true });
753
908
  return {
754
- summary: `Adopted stack for ${owner}/${repo}#${pr_number}`,
755
- data: { owner, repo, pr_number, result }
909
+ summary: withWatchText(watch, summary),
910
+ data: { owner, repo, pr_number, result, watch }
756
911
  };
757
912
  } catch (err) {
758
913
  const message = err instanceof Error ? err.message : String(err);
@@ -778,7 +933,7 @@ import {
778
933
  } from "mergestorm/client";
779
934
 
780
935
  // src/tools/stack-summary.ts
781
- import { stackBlockers, stackBlockersSummary } from "mergestorm/client";
936
+ import { stackBlockers, stackBlockersSummary as stackBlockersSummary2 } from "mergestorm/client";
782
937
  function stackSummary(stack, entries = []) {
783
938
  const count = stack.layers.length;
784
939
  const layerLabel = `${count} layer${count === 1 ? "" : "s"}`;
@@ -794,8 +949,8 @@ function stackSummary(stack, entries = []) {
794
949
  if (stack.cycloneOwnerMatch) {
795
950
  overrides.push(`cyclone-owner ${stack.cycloneOwnerMatch}`);
796
951
  }
797
- const { attention, issues, busy } = stackBlockers(stack, entries);
798
- return `${stack.owner}/${stack.repo} \xB7 ${layerLabel} \xB7 ${currentState} \xB7 auto-land ${stack.autoEnqueueWhenReady ? "on" : "off"}${overrides.map((label) => ` \xB7 ${label}`).join("")}${stackBlockersSummary(attention, issues, busy)}`;
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)}`;
799
954
  }
800
955
 
801
956
  // src/tools/stack-list.ts
@@ -825,108 +980,65 @@ async function stackList(cfg) {
825
980
  }
826
981
  }
827
982
 
828
- // src/tools/stack-wait.ts
829
- import {
830
- loadConfig as loadConfig6,
831
- stackBlockersSummary as stackBlockersSummary2,
832
- pollStackWatch,
833
- StackWatchError,
834
- StackWatchTimeoutError
835
- } from "mergestorm/client";
836
- import { z as z2 } from "zod";
837
- var stackWaitSchema = {
838
- stack_id: z2.string().trim().min(1),
839
- timeout_s: z2.number().finite().min(0).max(45).default(45),
840
- enrolled_head_sha: z2.string().nullable().optional(),
841
- after_finished_at: z2.string().nullable().optional(),
842
- bounce_id: z2.string().nullable().optional()
843
- };
844
- function stackWaitSummary(envelope) {
845
- const base = `Stack ${envelope.stackId} \xB7 ${envelope.status}${stackBlockersSummary2(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy)}${envelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`;
846
- if (envelope.status !== "waiting" && envelope.status !== "in_progress") return base;
847
- const selectors = [
848
- `stack_id ${JSON.stringify(envelope.cursor.stackId)}`,
849
- `enrolled_head_sha ${JSON.stringify(envelope.cursor.enrolledHeadSha)}`,
850
- ...envelope.cursor.afterFinishedAt !== void 0 ? [`after_finished_at ${JSON.stringify(envelope.cursor.afterFinishedAt)}`] : [],
851
- ...envelope.cursor.bounceId !== void 0 ? [`bounce_id ${JSON.stringify(envelope.cursor.bounceId)}`] : []
852
- ];
853
- return `${base} \xB7 watch not finished: call stack_wait again with timeout_s 45 and the same cursor: ${selectors.join(", ")}`;
854
- }
855
- async function stackWait(input, cfg, deps = {}) {
856
- const args = z2.object(stackWaitSchema).parse(input);
857
- const cursor = args.enrolled_head_sha !== void 0 || args.after_finished_at !== void 0 || args.bounce_id !== void 0 ? {
858
- stackId: args.stack_id,
859
- enrolledHeadSha: args.enrolled_head_sha ?? null,
860
- ...args.after_finished_at !== void 0 ? { afterFinishedAt: args.after_finished_at } : {},
861
- ...args.bounce_id !== void 0 ? { bounceId: args.bounce_id } : {}
862
- } : void 0;
863
- let envelope;
864
- try {
865
- envelope = await (deps.pollStackWatch ?? pollStackWatch)(cfg ?? await loadConfig6(), args.stack_id, {
866
- timeoutMs: args.timeout_s * 1e3,
867
- cursor,
868
- signal: deps.signal
869
- });
870
- } catch (err) {
871
- if (err instanceof StackWatchError) {
872
- return {
873
- summary: stackWaitSummary(err.lastEnvelope),
874
- data: {
875
- ...err.lastEnvelope,
876
- ...err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
877
- },
878
- isError: true
879
- };
880
- }
881
- if (!(err instanceof StackWatchTimeoutError)) throw err;
882
- envelope = err.lastEnvelope;
883
- }
884
- return {
885
- summary: stackWaitSummary(envelope),
886
- data: envelope
887
- };
888
- }
889
-
890
983
  // src/tools/stack-status.ts
891
984
  import {
892
985
  getEnrichedStack,
893
986
  listMergeQueueEntries as listMergeQueueEntries2,
894
987
  isCommandErrorCode as isCommandErrorCode5,
895
- stackBlockers as stackBlockers2
988
+ stackBlockers as stackBlockers2,
989
+ stackTerminalReason,
990
+ stackWatchObligation as stackWatchObligation5
896
991
  } from "mergestorm/client";
897
992
  async function stackStatus(stackId, cfg) {
898
993
  try {
899
994
  const stack = await getEnrichedStack(stackId, cfg);
900
995
  if (!stack) {
901
996
  const message = `Stack not found or not owned by the current user: ${stackId}`;
997
+ const watch2 = stackWatchObligation5({ stackId, terminal: "not_found" });
902
998
  return {
903
- summary: message,
999
+ summary: withWatchText(watch2, message),
904
1000
  data: {
905
1001
  error: {
906
1002
  code: "stack_not_found",
907
1003
  message,
908
1004
  stack_id: stackId
909
- }
1005
+ },
1006
+ watch: watch2
910
1007
  },
911
1008
  isError: true
912
1009
  };
913
1010
  }
914
1011
  const entries = await listMergeQueueEntries2(cfg, { stackId: stack.id });
915
- const { attention, issues, currentCandidate, busy, agents } = stackBlockers2(stack, entries);
1012
+ const { attention, held, issues, currentCandidate, busy, agents, repair, landGatePending } = stackBlockers2(stack, entries);
1013
+ const watch = stackWatchObligation5({
1014
+ stackId: stack.id,
1015
+ terminal: stackTerminalReason(stack),
1016
+ cursor: { enrolledHeadSha: currentCandidate?.headSha ?? null },
1017
+ attention: attention ? { prNumber: attention.prNumber, blocker: attention.blocker } : null,
1018
+ freshCursor: true
1019
+ });
916
1020
  return {
917
- summary: stackSummary(stack, entries),
918
- data: { stack, attention, issues, currentCandidate, busy, agents }
1021
+ summary: withWatchText(watch, `${stackSummary(stack, entries)}${repairSummary(repair)}`),
1022
+ data: { stack, attention, held, issues, currentCandidate, busy, agents, repair, landGatePending, watch }
919
1023
  };
920
1024
  } catch (err) {
921
1025
  if (isCommandErrorCode5(err, "rate_limited")) {
1026
+ const watch = stackWatchObligation5({
1027
+ stackId,
1028
+ terminal: null,
1029
+ status: "rate_limited",
1030
+ retryAfterSeconds: err.retryAfterSeconds,
1031
+ freshCursor: true
1032
+ });
922
1033
  return {
923
- summary: err.message,
1034
+ summary: withWatchText(watch, err.message),
924
1035
  data: {
925
1036
  error: {
926
1037
  code: "rate_limited",
927
1038
  message: err.message,
928
1039
  retry_after_seconds: err.retryAfterSeconds
929
- }
1040
+ },
1041
+ watch
930
1042
  },
931
1043
  isError: true
932
1044
  };
@@ -982,13 +1094,21 @@ var MCP_PR_LOOP_INSTRUCTIONS = [
982
1094
  "- Prefer the smallest correct patch. Do not refactor around a finding.",
983
1095
  "- Patch concrete bugs. A chat-only explanation is not a dismiss.",
984
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).`,
985
- "- 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."
1100
+ ].join("\n");
1101
+ var MCP_STACK_DONE_INSTRUCTIONS = [
1102
+ "When stack work is done:",
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.",
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
+ "- 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."
986
1106
  ].join("\n");
987
1107
  var MCP_STACK_BASE_INSTRUCTIONS = [
988
1108
  "Stack PR bases (Mergestorm sets these; leave them):",
989
1109
  "- 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>.",
990
1110
  "- 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.",
991
- "- 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 never rebase or merge layer 2+ onto it or onto main. mg-park-* is never a merge or rebase target.",
1111
+ "- 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>, or a CI fix on it, with an ordinary push (no force) is that repair. Never rebase or merge layer 2+ onto mg-stack-<n> or onto main. mg-park-* is never a merge or rebase target.",
992
1112
  "- 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."
993
1113
  ].join("\n");
994
1114
  var MCP_STACK_WATCH_INSTRUCTIONS = [
@@ -999,7 +1119,8 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
999
1119
  "- attention names the blocked PR via prNumber/headSha; currentCandidate is the promote candidate. Attention is not a mutation or authorization to change policy.",
1000
1120
  "- verifyHeadSha is a queue verification SHA, not the live PR head.",
1001
1121
  "- 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.",
1002
- "- 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. A hold can hide a blocker no agent clears, such as a Draft PR; if it looks stuck, refresh stack_status and tell the human.",
1122
+ "- 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.",
1123
+ '- A blocker no Vortex or Cyclone run clears (a merge conflict vs the live parent, a restack Conflict or Restack failed, a Draft PR) 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[].',
1003
1124
  "- 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.",
1004
1125
  "- mg-park-* is a frozen GitHub base, not a repair target: never merge or rebase onto mg-park-*.",
1005
1126
  "- Use the mergestorm-bounce-watch skill for the fix-then-watch policy."
@@ -1007,7 +1128,14 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
1007
1128
 
1008
1129
  // src/server.ts
1009
1130
  var MCP_SERVER_NAME = "mergestorm";
1010
- var MCP_SERVER_VERSION = "0.2.0";
1131
+ var MCP_SERVER_VERSION = "0.2.2";
1132
+ var MCP_SERVER_INSTRUCTIONS = [
1133
+ MCP_STACK_DONE_INSTRUCTIONS,
1134
+ MCP_PR_LOOP_INSTRUCTIONS,
1135
+ MCP_STACK_BASE_INSTRUCTIONS,
1136
+ MCP_STACK_WATCH_INSTRUCTIONS
1137
+ ].join("\n\n");
1138
+ var STACK_WATCH_RESULT_NOTE = ' The result carries watch {done, until: "landed", reason, next, message}: while watch.done is false the stack is not landed and your task is not done; call stack_wait with watch.next.args.';
1011
1139
  function stripNulls(value) {
1012
1140
  if (Array.isArray(value)) {
1013
1141
  return value.map((item) => stripNulls(item));
@@ -1044,11 +1172,7 @@ function createMergestormMcpServer() {
1044
1172
  websiteUrl: MCP_WEBSITE_URL,
1045
1173
  icons: MCP_SERVER_ICONS
1046
1174
  },
1047
- { instructions: `${MCP_PR_LOOP_INSTRUCTIONS}
1048
-
1049
- ${MCP_STACK_BASE_INSTRUCTIONS}
1050
-
1051
- ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1175
+ { instructions: MCP_SERVER_INSTRUCTIONS }
1052
1176
  );
1053
1177
  const addTool = server2.registerTool.bind(server2);
1054
1178
  addTool(
@@ -1181,11 +1305,38 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1181
1305
  }
1182
1306
  }
1183
1307
  );
1308
+ addTool(
1309
+ "review_dismiss",
1310
+ {
1311
+ title: "Dismiss Vortex findings",
1312
+ 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,
1313
+ inputSchema: {
1314
+ owner: z3.string().min(1),
1315
+ repo: z3.string().min(1),
1316
+ pr_number: z3.number().int().min(1),
1317
+ head_sha: z3.string().regex(/^[0-9a-fA-F]{40}$/),
1318
+ review_id: z3.number().int().min(1),
1319
+ finding_ids: z3.array(z3.string().min(1)).max(50).optional(),
1320
+ scope: z3.enum(["findings", "review"]).optional(),
1321
+ reason: z3.string().optional(),
1322
+ evidence_url: z3.string().url().optional(),
1323
+ preview: z3.boolean().optional()
1324
+ },
1325
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1326
+ },
1327
+ async (args) => {
1328
+ try {
1329
+ return ok(await reviewDismiss(args));
1330
+ } catch (err) {
1331
+ return fail(err);
1332
+ }
1333
+ }
1334
+ );
1184
1335
  addTool(
1185
1336
  "stack_adopt",
1186
1337
  {
1187
1338
  title: "Adopt PR stack",
1188
- description: "Adopt an open GitHub PR chain into a Mergestorm stack. auto_land is boolean; auto_review and auto_patch accept true, false, or null, where null clears the override. Omitted auto_land seeds from auto_land_default; omitted auto_review and auto_patch overrides are not seeded. Never changes account settings. Adoption requires a Cyclone GitHub App install; without it the API returns cyclone_not_connected \u2014 do not retry, tell the human. Read stack_status with result.stack.id afterward to verify policy and Cyclone ownership. With 2+ PRs, adopt moves the bottom PR's GitHub base to mg-stack-<n> and parks PR3+ on an mg-park-* freeze; a 1-PR stack stays on main. Leave those bases. Never retarget the bottom back to main.",
1339
+ description: "Adopt an open GitHub PR chain into a Mergestorm stack. auto_land is boolean; auto_review and auto_patch accept true, false, or null, where null clears the override. Omitted auto_land seeds from auto_land_default; omitted auto_review and auto_patch overrides are not seeded. Never changes account settings. Adoption requires a Cyclone GitHub App install; without it the API returns cyclone_not_connected \u2014 do not retry, tell the human. Read stack_status with result.stack.id afterward to verify policy and Cyclone ownership. With 2+ PRs, adopt moves the bottom PR's GitHub base to mg-stack-<n> and parks PR3+ on an mg-park-* freeze; a 1-PR stack stays on main. Leave those bases. Never retarget the bottom back to main." + STACK_WATCH_RESULT_NOTE,
1189
1340
  inputSchema: stackAdoptSchema,
1190
1341
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
1191
1342
  },
@@ -1216,7 +1367,7 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1216
1367
  "stack_set",
1217
1368
  {
1218
1369
  title: "Set stack policy",
1219
- description: "Set per-stack automation on one owned Mergestorm stack: auto_land arms or disarms Auto land; auto_review and auto_patch pin Vortex auto-review or Cyclone auto-patch for this stack only (true or false), and null clears the pin so the stack follows the account setting again. Pass at least one key. Never changes account settings.",
1370
+ description: "Set per-stack automation on one owned Mergestorm stack: auto_land arms or disarms Auto land; auto_review and auto_patch pin Vortex auto-review or Cyclone auto-patch for this stack only (true or false), and null clears the pin so the stack follows the account setting again. Pass at least one key. Never changes account settings." + STACK_WATCH_RESULT_NOTE,
1220
1371
  inputSchema: {
1221
1372
  stack_id: z3.string().min(1),
1222
1373
  auto_land: z3.boolean().optional(),
@@ -1243,7 +1394,7 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1243
1394
  "stack_status",
1244
1395
  {
1245
1396
  title: "Get stack status",
1246
- 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.",
1397
+ 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." + STACK_WATCH_RESULT_NOTE,
1247
1398
  inputSchema: {
1248
1399
  stack_id: z3.string().min(1)
1249
1400
  },
@@ -1261,7 +1412,7 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1261
1412
  "stack_wait",
1262
1413
  {
1263
1414
  title: "Wait for stack attention",
1264
- 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. 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. Attention never calls for changing a PR's GitHub base.",
1415
+ 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) 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.` + STACK_WATCH_RESULT_NOTE,
1265
1416
  inputSchema: stackWaitSchema,
1266
1417
  annotations: { readOnlyHint: true }
1267
1418
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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.25",
32
+ "mergestorm": "0.3.27",
33
33
  "zod": "^3.24.2"
34
34
  },
35
35
  "devDependencies": {