mergestorm 0.3.22 → 0.3.23

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 CHANGED
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▟██▛ mergestorm v0.3.22 │
48
+ │ ▟██▛ mergestorm v0.3.23 │
49
49
  │ ▜▙ ● msk_live_… · maelstrom │
50
50
  │ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
51
51
  │ │
52
52
  │ review a diff · usage for tabs · /help for all commands │
53
- │ New in v0.3.22: stack wait holds an unchanged queue instead of spinning│
53
+ │ New in v0.3.23: leave a stack bottom on mg-stack-<n> │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -104,7 +104,7 @@ mergestorm stack reset --force Clear local authoring state (not branches/PRs)
104
104
 
105
105
  If the parent branch is already a layer of a **registered** (submitted) stack, `create` and `submit` refuse unless you pass **`--extend`**. That blocks accidentally gluing an unrelated PR onto an open unit. Growing a registered stack on purpose: `mg stack create --onto <tip> --extend` → commit → `mg stack submit --extend`.
106
106
 
107
- `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push/PR auth is your local `git` + `gh auth` — not a GitHub App installation token.
107
+ `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; adopt then moves the bottom of a 2+ PR stack onto `mg-stack-<n>` — leave it there; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push is always your local `git` credentials. PRs open as you when `gh` is installed and `gh api user` succeeds; otherwise `stack submit` asks the Mergestorm API (`POST /api/v1/stacks/pulls`) to open them as Cyclone on your behalf, after checking your GitHub account has write access, and assigns you. Without either, submit stops before pushing and links the Cyclone install page.
108
108
 
109
109
  Legacy repo-local `.mergestorm/stack.json` state migrates automatically on the next stack command. If local authoring state is stale or malformed, use `mg stack reset --force`; this clears only pre-submit CLI state and never deletes branches, PRs, or registered stacks.
110
110
 
package/dist/api.d.ts CHANGED
@@ -225,6 +225,25 @@ export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<
225
225
  freezeBranch: string;
226
226
  created: boolean;
227
227
  }>;
228
+ export declare const CYCLONE_INSTALL_URL = "https://github.com/apps/mergestorm-cyclone/installations/new";
229
+ export declare function cycloneNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
230
+ export declare function findStackPull(cfg: Config, input: {
231
+ owner: string;
232
+ repo: string;
233
+ head: string;
234
+ }): Promise<number | null>;
235
+ export declare function openStackPull(cfg: Config, input: {
236
+ owner: string;
237
+ repo: string;
238
+ head: string;
239
+ base: string;
240
+ title: string;
241
+ body: string;
242
+ }): Promise<{
243
+ number: number;
244
+ url: string;
245
+ created: boolean;
246
+ }>;
228
247
  /** Arm or disarm Auto land on one owned stack. */
229
248
  export declare function setStackAutoLand(stackId: string, enabled: boolean, cfg?: Config): Promise<unknown>;
230
249
  /**
package/dist/api.js CHANGED
@@ -437,6 +437,56 @@ export async function ensureUpperPark(stackId, cfg) {
437
437
  created: body.created === true,
438
438
  };
439
439
  }
440
+ export const CYCLONE_INSTALL_URL = "https://github.com/apps/mergestorm-cyclone/installations/new";
441
+ export function cycloneNotInstalledMessage(owner, repo, installUrl = CYCLONE_INSTALL_URL) {
442
+ return (`Opening PRs needs either the GitHub CLI (gh auth login) or Cyclone installed on ` +
443
+ `${owner}/${repo} (${installUrl}).`);
444
+ }
445
+ function installUrlFrom(message) {
446
+ if (typeof message !== "string")
447
+ return CYCLONE_INSTALL_URL;
448
+ return message.match(/https:\/\/github\.com\/apps\/[^\s)]+\/installations\/new/)?.[0] ?? CYCLONE_INSTALL_URL;
449
+ }
450
+ function stackPullFailure(status, body, owner, repo, failLabel) {
451
+ const rejection = (body && typeof body === "object" ? body : {});
452
+ const text = `${String(rejection.error ?? "")} ${String(rejection.message ?? "")}`;
453
+ if (rejection.error === "cyclone_not_installed" ||
454
+ /not installed|installation[^\n]*not found/i.test(text)) {
455
+ return new CommandError(cycloneNotInstalledMessage(owner, repo, installUrlFrom(rejection.message)), 1, "cyclone_not_installed");
456
+ }
457
+ if (status === 404) {
458
+ return new CommandError("Stacks API cannot open PRs on this server yet. Install the GitHub CLI (gh auth login) and retry.");
459
+ }
460
+ const detail = typeof rejection.message === "string" ? rejection.message : JSON.stringify(body);
461
+ return new CommandError(`${failLabel} (HTTP ${status}): ${detail}`);
462
+ }
463
+ export async function findStackPull(cfg, input) {
464
+ const query = new URLSearchParams(input).toString();
465
+ const { status, body } = await apiFetch(cfg, `/api/v1/stacks/pulls?${query}`);
466
+ if (status !== 200) {
467
+ throw stackPullFailure(status, body, input.owner, input.repo, `Failed to look up the PR for ${input.head}`);
468
+ }
469
+ const n = body?.number;
470
+ return typeof n === "number" && n > 0 ? n : null;
471
+ }
472
+ export async function openStackPull(cfg, input) {
473
+ const { status, body } = await apiFetch(cfg, "/api/v1/stacks/pulls", {
474
+ method: "POST",
475
+ json: input,
476
+ });
477
+ if (status !== 200 && status !== 201) {
478
+ throw stackPullFailure(status, body, input.owner, input.repo, `Failed to open PR ${input.head} → ${input.base}`);
479
+ }
480
+ const row = (body ?? {});
481
+ if (typeof row.number !== "number" || row.number <= 0) {
482
+ throw new CommandError(`Failed to open PR ${input.head} → ${input.base}: no pull request number returned`);
483
+ }
484
+ return {
485
+ number: row.number,
486
+ url: typeof row.url === "string" ? row.url : "",
487
+ created: row.created === true,
488
+ };
489
+ }
440
490
  async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg) {
441
491
  const resolved = cfg ?? (await loadConfig());
442
492
  const { status, body } = await apiFetch(resolved, `/api/v1/stacks/${encodeURIComponent(stackId)}${pathSuffix}`, method === "PATCH"
@@ -1,4 +1,4 @@
1
- import { adoptStack, ensureUpperPark, getEnrichedStack, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { createPrs, findOpenPrNumber } from "../gh.js";
4
4
  import { parseGithubOriginRepo } from "../git.js";
@@ -106,7 +106,7 @@ export type StackSubmitDeps = {
106
106
  loadStackMeta?: typeof loadStackMeta;
107
107
  saveStackMeta?: typeof saveStackMeta;
108
108
  parseGithubOriginRepo?: typeof parseGithubOriginRepo;
109
- requireGh?: (cwd?: string) => void;
109
+ ghReady?: (cwd?: string) => boolean;
110
110
  branchExists?: typeof branchExists;
111
111
  fetchRemoteBranch?: typeof fetchRemoteBranch;
112
112
  pushBranch?: typeof pushBranch;
@@ -116,6 +116,8 @@ export type StackSubmitDeps = {
116
116
  tipCommitMessage?: typeof tipCommitMessage;
117
117
  buildPrBodyFromCommit?: typeof buildPrBodyFromCommit;
118
118
  createPrs?: typeof createPrs;
119
+ findStackPull?: typeof findStackPull;
120
+ openStackPull?: typeof openStackPull;
119
121
  loadConfig?: typeof loadConfig;
120
122
  listStacks?: typeof listStacks;
121
123
  adoptStack?: typeof adoptStack;
@@ -1,8 +1,8 @@
1
1
  import { stackBlockersSummary } from "../stack-blockers.js";
2
- import { adoptStack, ensureUpperPark, getEnrichedStack, landNextStack, listStacks, restackStack, setStackPolicy, } from "../api.js";
2
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
5
- import { createPrs, findOpenPrNumber, githubApiFromEnv, requireGh, } from "../gh.js";
5
+ import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
6
6
  import { parseGithubOriginRepo } from "../git.js";
7
7
  import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
8
8
  import { buildPrBodyFromCommit } from "../pr-body.js";
@@ -33,6 +33,9 @@ const STACK_USAGE = `usage:
33
33
  stack submit opens PRs with a body generated from each layer tip commit.
34
34
  Layers 1–2 use the git parent as GitHub base. Layer 3+ opens onto the
35
35
  review-unit park freeze (mg-park-*) so adopt does not retarget after open.
36
+ After adopt, the bottom of a 2+ PR stack is based on the owned trunk
37
+ mg-stack-<n>; leave it (do not retarget it to trunk). A 1-PR stack stays on
38
+ trunk.
36
39
  stack submit talks to GitHub GraphQL and falls back to the REST API when
37
40
  GraphQL is blocked; --rest (or MERGESTORM_GITHUB_API=rest) forces REST.
38
41
  Parenting onto a branch that already belongs to a registered (submitted)
@@ -545,7 +548,7 @@ export async function cmdStackSubmit(args, deps = {}) {
545
548
  const loadStackMetaFn = deps.loadStackMeta ?? loadStackMeta;
546
549
  const saveStackMetaFn = deps.saveStackMeta ?? saveStackMeta;
547
550
  const parseOriginFn = deps.parseGithubOriginRepo ?? parseGithubOriginRepo;
548
- const requireGhFn = deps.requireGh ?? requireGh;
551
+ const ghReadyFn = deps.ghReady ?? ghReady;
549
552
  const branchExistsFn = deps.branchExists ?? branchExists;
550
553
  const fetchRemoteBranchFn = deps.fetchRemoteBranch ?? fetchRemoteBranch;
551
554
  const pushBranchFn = deps.pushBranch ?? pushBranch;
@@ -555,6 +558,8 @@ export async function cmdStackSubmit(args, deps = {}) {
555
558
  const tipCommitMessageFn = deps.tipCommitMessage ?? tipCommitMessage;
556
559
  const buildPrBodyFn = deps.buildPrBodyFromCommit ?? buildPrBodyFromCommit;
557
560
  const createPrsFn = deps.createPrs ?? createPrs;
561
+ const findStackPullFn = deps.findStackPull ?? findStackPull;
562
+ const openStackPullFn = deps.openStackPull ?? openStackPull;
558
563
  const loadConfigFn = deps.loadConfig ?? loadConfig;
559
564
  const listStacksFn = deps.listStacks ?? listStacks;
560
565
  const adoptStackFn = deps.adoptStack ?? adoptStack;
@@ -575,7 +580,27 @@ export async function cmdStackSubmit(args, deps = {}) {
575
580
  throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
576
581
  }
577
582
  const { owner, repo } = origin;
578
- requireGhFn(cwd);
583
+ let opener;
584
+ if (ghReadyFn(cwd)) {
585
+ opener = {
586
+ findOpen: async (branch) => findOpenPrNumberFn(owner, repo, branch, cwd, { api }),
587
+ create: async (prs) => createPrsFn({ owner, repo, prs, cwd, api }).map((number) => ({ number, created: true })),
588
+ };
589
+ }
590
+ else {
591
+ const apiCfg = await loadConfigFn();
592
+ opener = {
593
+ findOpen: (branch) => findStackPullFn(apiCfg, { owner, repo, head: branch }),
594
+ create: async (prs) => {
595
+ const rows = [];
596
+ for (const pr of prs) {
597
+ const row = await openStackPullFn(apiCfg, { owner, repo, ...pr });
598
+ rows.push({ number: row.number, created: row.created });
599
+ }
600
+ return rows;
601
+ },
602
+ };
603
+ }
579
604
  // Safety net: refuse submit that would open/register onto a registered tip
580
605
  // without --extend. Check every layer's parent (mid-stack layers from older
581
606
  // local state must not sail through), scoped to this repo, and fail closed
@@ -625,6 +650,10 @@ export async function cmdStackSubmit(args, deps = {}) {
625
650
  autoReview: pick(autoReview, stored?.autoReviewOverride),
626
651
  autoPatch: pick(autoPatch, stored?.autoPatchOverride),
627
652
  });
653
+ const existingPrs = new Map();
654
+ for (const plan of plans) {
655
+ existingPrs.set(plan.branch, await opener.findOpen(plan.branch));
656
+ }
628
657
  for (const layer of layers) {
629
658
  if (!branchExistsFn(layer.branch, cwd)) {
630
659
  throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
@@ -638,11 +667,7 @@ export async function cmdStackSubmit(args, deps = {}) {
638
667
  }
639
668
  }
640
669
  const opened = [];
641
- const pendingPark = plans.filter((plan) => {
642
- if (findOpenPrNumberFn(owner, repo, plan.branch, cwd, { api }) != null)
643
- return false;
644
- return plan.githubBase == null;
645
- });
670
+ const pendingPark = plans.filter((plan) => existingPrs.get(plan.branch) == null && plan.githubBase == null);
646
671
  const prepareLayerPr = (branch, base) => {
647
672
  const aheadRef = isMgParkBranch(base) ? `origin/${base}` : base;
648
673
  if (isMgParkBranch(base)) {
@@ -674,11 +699,11 @@ export async function cmdStackSubmit(args, deps = {}) {
674
699
  body: buildPrBodyFn(tipCommitMessageFn(branch, cwd)),
675
700
  };
676
701
  };
677
- const openLayerWave = (wave) => {
702
+ const openLayerWave = async (wave) => {
678
703
  const rows = [];
679
704
  const pending = [];
680
705
  for (const { branch, base } of wave) {
681
- const existing = findOpenPrNumberFn(owner, repo, branch, cwd, { api });
706
+ const existing = existingPrs.get(branch) ?? null;
682
707
  if (existing != null) {
683
708
  progress(ansi.dim(` PR #${existing} already open for ${branch}`));
684
709
  rows.push({ branch, base, prNumber: existing, created: false });
@@ -692,19 +717,19 @@ export async function cmdStackSubmit(args, deps = {}) {
692
717
  for (const { pr } of pending) {
693
718
  progress(ansi.dim(` Opening PR ${pr.head} → ${pr.base} …`));
694
719
  }
695
- const numbers = createPrsFn({ owner, repo, prs: pending.map((p) => p.pr), cwd, api });
720
+ const numbers = await opener.create(pending.map((p) => p.pr));
696
721
  if (numbers.length !== pending.length) {
697
722
  throw new CommandError(`GitHub returned ${numbers.length} PR numbers for ${pending.length} new pull requests.`);
698
723
  }
699
724
  pending.forEach(({ index }, i) => {
700
- rows[index].prNumber = numbers[i];
725
+ rows[index].prNumber = numbers[i].number;
726
+ rows[index].created = numbers[i].created;
701
727
  });
702
728
  return rows;
703
729
  };
704
- opened.push(...openLayerWave(plans
705
- .filter((plan) => plan.githubBase != null ||
706
- findOpenPrNumberFn(owner, repo, plan.branch, cwd, { api }) != null)
707
- .map((plan) => ({ branch: plan.branch, base: plan.githubBase ?? plan.gitParent }))));
730
+ opened.push(...(await openLayerWave(plans
731
+ .filter((plan) => plan.githubBase != null || existingPrs.get(plan.branch) != null)
732
+ .map((plan) => ({ branch: plan.branch, base: plan.githubBase ?? plan.gitParent })))));
708
733
  const cfg = await loadConfigFn();
709
734
  let adoptData = null;
710
735
  let stackId = plans.find((plan) => plan.stackId)?.stackId ?? null;
@@ -735,7 +760,7 @@ export async function cmdStackSubmit(args, deps = {}) {
735
760
  }
736
761
  progress(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
737
762
  const park = await ensureUpperParkFn(stackId, cfg);
738
- opened.push(...openLayerWave(pendingPark.map((plan) => ({ branch: plan.branch, base: park.freezeBranch }))));
763
+ opened.push(...(await openLayerWave(pendingPark.map((plan) => ({ branch: plan.branch, base: park.freezeBranch })))));
739
764
  await registerPr(opened[opened.length - 1].prNumber);
740
765
  }
741
766
  else {
@@ -763,6 +788,10 @@ export async function cmdStackSubmit(args, deps = {}) {
763
788
  const verb = row.created ? "opened" : "exists";
764
789
  summary.push(ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
765
790
  }
791
+ const adoptedTrunk = data.stack?.trunkBranch;
792
+ if (adoptedTrunk?.startsWith("mg-stack-")) {
793
+ summary.push(ansi.dim(` Bottom base is now ${adoptedTrunk} (Mergestorm-owned trunk). Leave it.`));
794
+ }
766
795
  if (stackId) {
767
796
  summary.push(ansi.brightGreen(` Registered stack ${stackId}`));
768
797
  }
package/dist/gh.d.ts CHANGED
@@ -28,7 +28,7 @@ type GhSpawn = (command: string, args: string[], options: {
28
28
  status: number | null;
29
29
  };
30
30
  export declare function runGh(args: string[], cwd?: string, input?: string, spawn?: GhSpawn): GhResult;
31
- export declare function requireGh(cwd?: string): void;
31
+ export declare function ghReady(cwd?: string, gh?: GhRunner): boolean;
32
32
  /** Open PR number for `head` branch in owner/repo, or null. */
33
33
  export declare function findOpenPrNumber(owner: string, repo: string, headBranch: string, cwd?: string, options?: {
34
34
  api?: GithubApi;
package/dist/gh.js CHANGED
@@ -40,15 +40,8 @@ export function runGh(args, cwd = process.cwd(), input, spawn = spawnSync) {
40
40
  }
41
41
  return { ok: true, stdout: result.stdout ?? "" };
42
42
  }
43
- export function requireGh(cwd = process.cwd()) {
44
- const check = runGh(["--version"], cwd);
45
- if (!check.ok) {
46
- throw new CommandError("GitHub CLI (`gh`) is required for `stack submit`. Install https://cli.github.com and run `gh auth login`.");
47
- }
48
- const auth = runGh(["auth", "status"], cwd);
49
- if (!auth.ok) {
50
- throw new CommandError("`gh` is installed but not authenticated. Run `gh auth login`, then retry `stack submit`.");
51
- }
43
+ export function ghReady(cwd = process.cwd(), gh = (args) => runGh(args, cwd)) {
44
+ return gh(["--version"]).ok && gh(["api", "user"]).ok;
52
45
  }
53
46
  /** Open PR number for `head` branch in owner/repo, or null. */
54
47
  export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd(), options = {}) {
package/dist/ui/banner.js CHANGED
@@ -99,6 +99,9 @@ const WHATS_NEW = {
99
99
  "0.3.22": [
100
100
  "stack wait holds an unchanged queue instead of spinning",
101
101
  ],
102
+ "0.3.23": [
103
+ "leave a stack bottom on mg-stack-<n>",
104
+ ],
102
105
  };
103
106
  function densityFromVariant(variant) {
104
107
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,7 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
18
18
  1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument.
19
19
  2. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
20
20
  3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy.
21
- 4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch.
21
+ 4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
22
22
  5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention.
23
23
  6. Recheck Auto land and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
24
24
  7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
@@ -48,4 +48,5 @@ A kind is context, not permission to mutate queue or stack policy. Diagnose the
48
48
  - Do not replace Cyclone or change stack auto-patch policy to take over its work.
49
49
  - Do not auto re-arm Auto land or a bounced queue entry.
50
50
  - Do not force-push except the same-live-parent rebase with the explicit starting-SHA lease above. Never rebase and retry after the remote head moved.
51
+ - Do not change any PR's GitHub base (`gh pr edit --base` or the REST equivalent). Never move a bottom from `mg-stack-N` back to main or a parked PR off `mg-park-*`.
51
52
  - Do not push any branch other than the named blocker's PR head (or the one selected live CI issue after the gate is clean).
@@ -30,6 +30,7 @@ mg skill install --claude --cursor --agents
30
30
  - Adoption needs the Cyclone GitHub App on the account; without it `stack_adopt` returns `cyclone_not_connected` — stop and tell the human, do not retry.
31
31
  - If there is no stack and the human asked you to mute Cyclone or continue the loop: call `stack_adopt` with `{ owner, repo, pr_number, auto_patch: false }` (equivalent to `mg stack adopt … --auto-patch off`). Then call `stack_status` with the returned `result.stack.id`, confirm this PR is in the stack and `autoPatchOverride` is `false`. If adoption or verification fails, refuse the loop. Otherwise continue through the ownership check below and the remaining loop steps; do not stop and ask the human to adopt. Do not turn account auto-patch off.
32
32
  - Adoption does not fix installer ≠ key. After adoption, the same `cycloneOwnerMatch` refusal below still applies.
33
+ - After adopting 2+ PRs the bottom PR's GitHub base is `mg-stack-<n>` and PR3+ sit on an `mg-park-*` freeze; a 1-PR stack stays on main. That is expected; leave every base as Mergestorm set it.
33
34
  - Read `cycloneOwnerMatch` on that stack. If it is missing, `lookup_failed`, or `different`: refuse the loop. Do not wait, patch, or push. Tell the human the API key and the Cyclone GitHub App install are different Mergestorm accounts (or the comparison could not be loaded). Cyclone still patches as the installer. Do not turn account auto-patch off, and do not set `auto_patch: false` to paper over this.
34
35
  - If it is `same` or `none`: continue. `none` means no Cyclone install monitors this repo.
35
36
  2. Call `settings_get` and read `auto_patch_enabled`.
@@ -73,6 +74,7 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
73
74
  - treat the key user's mute or `auto_patch_enabled` as covering a different Cyclone install user
74
75
  - set `auto_patch: true` on any stack
75
76
  - push any branch other than the PR head
77
+ - change a PR's GitHub base (`gh pr edit --base` or the REST equivalent), including moving a bottom from `mg-stack-<n>` back to main. If one was moved, stop and tell the human. Do not run stack land or re-adopt to repair it.
76
78
  - force-push, or rebase and retry a push that was rejected because the remote moved
77
79
  - patch a pass whose `head_sha` is not the commit you have checked out
78
80
  - wrap the CLI in a shell escape hatch