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.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/dist/index.js +205 -113
  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, 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 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,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 stackWatchObligation3 } from "mergestorm/client";
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 stackWatchObligation2 } from "mergestorm/client";
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 = stackWatchObligation2({ stackId, terminal: null, unread: true, freshCursor: true });
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 = stackWatchObligation3({ stackId, terminal: null, unread: true, freshCursor: true });
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 stackWatchObligation4
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 = stackWatchObligation4({ stackId, terminal: "not_found" });
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 = stackWatchObligation4({
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 = stackWatchObligation4({
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 never rebase or merge layer 2+ onto it or onto main. mg-park-* is never a merge or rebase target.",
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. 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.",
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.1";
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: "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." + STACK_WATCH_RESULT_NOTE,
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: "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. Attention never calls for changing a PR's GitHub base. 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,
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.1",
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.26",
32
+ "mergestorm": "0.3.28",
33
33
  "zod": "^3.24.2"
34
34
  },
35
35
  "devDependencies": {