mergestorm 0.3.62 → 0.3.63

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.62 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.63 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
52
52
  │ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
53
- │ ▀ New in v0.3.62: Watch wakes for a blocker on any layer │
53
+ │ ▀ New in v0.3.63: Stack create starts a new stack unless --extend│
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -93,7 +93,7 @@ mergestorm thread <slug> Jobs in a review thread
93
93
  mergestorm issue create --title <t> Open a GitHub issue through Mergestorm, no `gh` (`--body`, `--body-file`, `--label`, `--repo`)
94
94
  mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk` / `--extend`)
95
95
  mergestorm stack submit Push layers, open PRs (`gh`), register via adopt (optional `--extend`, `--server-side`)
96
- mergestorm stack publish Publish the current branch through Mergestorm with no GitHub credentials (`--status` to read it)
96
+ mergestorm stack publish Publish the current branch through Mergestorm with no GitHub credentials (`--base <branch> --extend` to stack, `--status` to read it)
97
97
  mergestorm stack list [--json] List registered stacks
98
98
  mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
99
99
  mergestorm stack restack <stack-id> Restack descendants
@@ -138,9 +138,14 @@ Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it
138
138
 
139
139
  `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. On review-unit stacks, `stack land` **promotes** the tip into the unit (same gates as the dashboard); otherwise it lands the bottom open PR. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`). Unattended landing is `mg queue add`.
140
140
 
141
- `stack create` checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and records `{ branch, parentBranch }` in CLI-managed state under **`~/.mergestorm/stacks/<repo-id>/stack.json`**. The CLI updates this automatically; never edit it. Each worktree has its own authoring state; independent clones stay isolated too. Creating onto trunk with a non-empty active stack starts a new one. `--trunk` only sets trunk metadata — it does **not** change the parent; use `--onto main` (etc.) for a fresh stack from trunk.
141
+ `stack create` always starts a new single PR from trunk unless you pass **`--extend`**, even when the worktree is still on an earlier PR's branch: it checks out the new branch from `origin/<trunk>` (or local trunk; from `HEAD` when trunk is checked out). It discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`) and records `{ branch, parentBranch }` in CLI-managed state under **`~/.mergestorm/stacks/<repo-id>/stack.json`**. The CLI updates this automatically; never edit it. Each worktree has its own authoring state; independent clones stay isolated too. Creating onto trunk with a non-empty active stack starts a new one.
142
142
 
143
- 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`.
143
+ Both paths are one command:
144
+
145
+ - New stack from trunk: `mg stack create <name>` (from any branch), then `mg stack submit`.
146
+ - Grow a stack: `mg stack create <name> --extend` on the branch to stack onto (or `--onto <branch> --extend`), then `mg stack submit --extend` when the parent is a layer of a registered stack. `mg stack publish --base <branch> --extend` does the same for publish.
147
+
148
+ `--onto` or `--base` with a branch other than trunk and no `--extend` is refused, so nothing is added to a stack by accident. Every create, submit and publish prints which one it made: a new single PR onto trunk, or a layer stacked on its parent.
144
149
 
145
150
  `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 the Mergestorm Surge App on your behalf, after checking your GitHub account has write access, and assigns you. Stacks, the merge queue and Auto land run through Surge, and Cyclone is the auto-patch App. Without Surge, submit stops before pushing and prints the server's message, which names the App to install and links its install page. `mg settings` prints a `Stacks, merge queue, Auto land` line when the server reports whether that App is ready.
146
151
 
package/dist/api.d.ts CHANGED
@@ -142,6 +142,14 @@ export type PrVortexReview = {
142
142
  reviewed_at: string | null;
143
143
  finding_count: number;
144
144
  findings: PrReviewFindings | null;
145
+ dismissed_findings?: Array<{
146
+ path: string;
147
+ line: number;
148
+ title: string;
149
+ severity: string;
150
+ finding_key: string;
151
+ github_comment_id: number | null;
152
+ }>;
145
153
  patch_policy: unknown | null;
146
154
  /**
147
155
  * MS-01: True when the returned row is a resting completion but a newer
@@ -458,7 +466,12 @@ export type PrFindingDismissResult = {
458
466
  other_gates: "unchanged";
459
467
  };
460
468
  stack_id: string | null;
469
+ threads?: Array<{
470
+ finding_id: string;
471
+ state: "resolved" | "already_resolved" | "not_found" | "failed";
472
+ }>;
461
473
  };
474
+ export declare function dismissThreadsLine(result: Pick<PrFindingDismissResult, "threads">): string | null;
462
475
  export type PrFindingDismissOutcome = {
463
476
  ok: true;
464
477
  result: PrFindingDismissResult;
package/dist/api.js CHANGED
@@ -773,6 +773,19 @@ export async function listThreads(limit = 20, cfg) {
773
773
  const items = body.items;
774
774
  return Array.isArray(items) ? items : [];
775
775
  }
776
+ export function dismissThreadsLine(result) {
777
+ const threads = result.threads ?? [];
778
+ if (threads.length === 0)
779
+ return null;
780
+ const closed = threads.filter((thread) => thread.state === "resolved" || thread.state === "already_resolved").length;
781
+ const failed = threads.filter((thread) => thread.state === "failed").map((thread) => thread.finding_id);
782
+ const missing = threads.filter((thread) => thread.state === "not_found").map((thread) => thread.finding_id);
783
+ return [
784
+ `GitHub threads: ${closed} of ${threads.length} resolved.`,
785
+ ...(failed.length ? [`Could not resolve ${failed.join(", ")}; run the same dismissal again to retry.`] : []),
786
+ ...(missing.length ? [`No thread found for ${missing.join(", ")}.`] : []),
787
+ ].join(" ");
788
+ }
776
789
  export async function dismissPrFindings(input, cfg, opts) {
777
790
  const resolved = cfg ?? (await loadConfig());
778
791
  const json = {
@@ -1,6 +1,6 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, type Config, } from "../config.js";
2
2
  export { BEARER_SETTINGS, BEARER_BOOLEAN_SETTINGS_KEYS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, type BearerSettingsKey, } from "../automation-catalog.js";
3
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrDismissedFinding, type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type RepoSettings, type RepoSettingsKey, type RepoSettingsPatch, type SettingsResponse, type StackDto, type StackPolicyPatch, type StackPublication, type StackPublicationRequest, type StackPublicationStatus, type ThreadDetail, type ThreadListItem, } from "../api.js";
3
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, dismissThreadsLine, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrDismissedFinding, type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type RepoSettings, type RepoSettingsKey, type RepoSettingsPatch, type SettingsResponse, type StackDto, type StackPolicyPatch, type StackPublication, type StackPublicationRequest, type StackPublicationStatus, type ThreadDetail, type ThreadListItem, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, type CommandErrorCode, type CommandErrorExtras, } from "../errors.js";
5
5
  export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, noPrReviewYetMessage, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
@@ -1,6 +1,6 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, } from "../config.js";
2
2
  export { BEARER_SETTINGS, BEARER_BOOLEAN_SETTINGS_KEYS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, } from "../automation-catalog.js";
3
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, } from "../api.js";
3
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, dismissPrFindings, dismissThreadsLine, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, listRepoSettings, patchRepoSettings, REPO_SETTINGS_KEYS, getStackPublication, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, openStackIssue, parseRetryAfterSeconds, patchSettings, restackStack, setStackPolicy, stackPolicyBody, submitStackPublication, } from "../api.js";
4
4
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
5
5
  export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, noPrReviewYetMessage, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
6
6
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
@@ -1,4 +1,4 @@
1
- import { dismissPrFindings, } from "../api.js";
1
+ import { dismissPrFindings, dismissThreadsLine, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError, REVIEW_EXIT } from "../errors.js";
4
4
  import { stackWatchObligation } from "../stack-watch-obligation.js";
@@ -110,6 +110,9 @@ export function formatDismissResult(result, watch) {
110
110
  }
111
111
  if (result.remaining.length)
112
112
  lines.push("Still open:", ...result.remaining.map(findingLine));
113
+ const threads = dismissThreadsLine(result);
114
+ if (threads)
115
+ lines.push(threads);
113
116
  }
114
117
  const seam = result.gate.seam;
115
118
  if (seam) {
@@ -4,7 +4,7 @@ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getStackP
4
4
  import { loadConfig } from "../config.js";
5
5
  import { createPrs, findOpenPrNumber } from "../gh.js";
6
6
  import { parseGithubOriginRepo } from "../git.js";
7
- import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty } from "../git-stack.js";
7
+ import { branchExists, commitsAheadOf, createBranchFrom, createBranchFromHead, trunkStartRef, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty } from "../git-stack.js";
8
8
  import { buildPrBodyFromCommit } from "../pr-body.js";
9
9
  import { publishBranchServerSide, type WorkingBranchDeps } from "../server-publish.js";
10
10
  import { gitTopLevel } from "../repo-identity.js";
@@ -33,6 +33,11 @@ export declare function findRegisteredParent(stacks: StackDto[], parentBranch: s
33
33
  * Local unsubmitted parents are fine; only API-listed layers trip this.
34
34
  */
35
35
  export declare function assertMayParentOntoRegistered(parentBranch: string, stacks: StackDto[], extend: boolean, verb: "create" | "submit", owner?: string, repo?: string): RegisteredParentHit | null;
36
+ export declare function stackShapeLine(input: {
37
+ layers: number;
38
+ base: string;
39
+ extendedStack: boolean;
40
+ }): string;
36
41
  export declare function requireStackId(raw: string | undefined, usage: string): string;
37
42
  /** Parse `owner/repo#12` or `owner/repo 12`. */
38
43
  export declare function parseAdoptTarget(args: string[]): {
@@ -110,6 +115,8 @@ export type StackCreateDeps = {
110
115
  currentBranch?: typeof currentBranch;
111
116
  branchExists?: typeof branchExists;
112
117
  createBranchFromHead?: typeof createBranchFromHead;
118
+ createBranchFrom?: typeof createBranchFrom;
119
+ trunkStartRef?: typeof trunkStartRef;
113
120
  deleteBranch?: typeof deleteBranch;
114
121
  discoverTrunk?: typeof discoverTrunk;
115
122
  defaultLayerBranchName?: typeof defaultLayerBranchName;
@@ -156,6 +163,7 @@ export declare function parseStackPublishArgs(args: string[]): {
156
163
  title?: string;
157
164
  replaceHead?: string;
158
165
  autoLand?: boolean;
166
+ extend: boolean;
159
167
  status: boolean;
160
168
  asJson: boolean;
161
169
  };
@@ -5,9 +5,9 @@ import { loadConfig } from "../config.js";
5
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
6
6
  import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
7
7
  import { parseGithubOriginRepo } from "../git.js";
8
- import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
8
+ import { branchExists, commitsAheadOf, createBranchFrom, createBranchFromHead, trunkStartRef, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
9
9
  import { buildPrBodyFromCommit } from "../pr-body.js";
10
- import { publishBranchServerSide, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
10
+ import { publishBranchServerSide, assertExplicitStacking, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
11
11
  import { gitTopLevel } from "../repo-identity.js";
12
12
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
13
13
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
@@ -52,7 +52,7 @@ export function openStackWatch(stackId, opts = {}) {
52
52
  const STACK_USAGE = `usage:
53
53
  mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
54
54
  mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--rest] [--server-side] [--json]
55
- mergestorm stack publish [--branch <branch>] [--base <branch>] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]
55
+ mergestorm stack publish [--branch <branch>] [--base <branch>] [--extend] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]
56
56
  mergestorm stack reset --force
57
57
  mergestorm stack list [--json]
58
58
  mergestorm stack status <stack-id> [--json]
@@ -95,10 +95,13 @@ const STACK_USAGE = `usage:
95
95
  you name that head with --replace-head. stack publish --status reads how far
96
96
  a branch got (absent, pushed, opened, registered) without changing anything;
97
97
  after a lost response or a failure, run the same command again to resume.
98
- Parenting onto a branch that already belongs to a registered (submitted)
99
- stack requires --extend on create and submit — otherwise mg starts a new
100
- stack from trunk. stack land promotes into the review unit when one exists;
101
- otherwise lands the bottom open PR (unit-less stacks).`;
98
+ stack create starts a new single PR from trunk, even on another PR's
99
+ branch. --extend stacks it on the checked-out branch (or --onto <branch>)
100
+ instead; --onto or publish --base a branch other than trunk without
101
+ --extend is refused. A layer on a registered stack also needs --extend on
102
+ submit. create, submit and publish say which one they made. stack land
103
+ promotes into the review unit when one exists; otherwise lands the bottom
104
+ open PR (unit-less stacks).`;
102
105
  /** Cap the registered-stack guard's API probe so it cannot stall local authoring. */
103
106
  const STACK_GUARD_TIMEOUT_MS = 8_000;
104
107
  async function listRegisteredStacksRetryingOnce(list, cfg) {
@@ -152,6 +155,13 @@ export function assertMayParentOntoRegistered(parentBranch, stacks, extend, verb
152
155
  `• New independent stack: check out trunk and pass \`--onto <trunk>\` (e.g. \`mg stack create --onto main\`).\n` +
153
156
  `• Intentionally grow that stack: pass \`--extend\` on \`stack ${verb}\`.`, 1, "registered_parent");
154
157
  }
158
+ export function stackShapeLine(input) {
159
+ if (input.extendedStack)
160
+ return ` Added ${input.layers === 1 ? "1 layer" : `${input.layers} layers`} to an existing stack (--extend).`;
161
+ if (input.layers === 1)
162
+ return ` A new single PR onto ${input.base}.`;
163
+ return ` A new stack of ${input.layers} layers onto ${input.base}.`;
164
+ }
155
165
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
156
166
  export function requireStackId(raw, usage) {
157
167
  const id = raw?.trim() ?? "";
@@ -525,6 +535,8 @@ export async function cmdStackCreate(args, deps = {}) {
525
535
  const currentBranchFn = deps.currentBranch ?? currentBranch;
526
536
  const branchExistsFn = deps.branchExists ?? branchExists;
527
537
  const createBranchFromHeadFn = deps.createBranchFromHead ?? createBranchFromHead;
538
+ const createBranchFromFn = deps.createBranchFrom ?? createBranchFrom;
539
+ const trunkStartRefFn = deps.trunkStartRef ?? trunkStartRef;
528
540
  const deleteBranchFn = deps.deleteBranch ?? deleteBranch;
529
541
  const discoverTrunkFn = deps.discoverTrunk ?? discoverTrunk;
530
542
  const defaultLayerBranchNameFn = deps.defaultLayerBranchName ?? defaultLayerBranchName;
@@ -542,17 +554,6 @@ export async function cmdStackCreate(args, deps = {}) {
542
554
  if (worktreeDirtyFn(cwd)) {
543
555
  throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
544
556
  }
545
- const parentBranch = onto?.trim() || currentBranchFn(cwd);
546
- if (!parentBranch) {
547
- throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) before `stack create`.");
548
- }
549
- if (!branchExistsFn(parentBranch, cwd)) {
550
- throw new CommandError(`Parent branch not found: ${parentBranch}`);
551
- }
552
- const branch = defaultLayerBranchNameFn(name);
553
- if (branchExistsFn(branch, cwd)) {
554
- throw new CommandError(`Branch already exists: ${branch}`);
555
- }
556
557
  let meta = await loadStackMetaFn(cwd);
557
558
  const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunkFn(cwd);
558
559
  if (!meta) {
@@ -561,8 +562,34 @@ export async function cmdStackCreate(args, deps = {}) {
561
562
  else if (trunkFlag?.trim() && meta.trunk !== trunk) {
562
563
  throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Run \`mg stack reset --force\` to discard local pre-submit state.`);
563
564
  }
564
- // --trunk only sets metadata; parent defaults to current branch. Call that out
565
- // when someone passes --trunk while sitting on a different branch without --onto.
565
+ const checkedOut = currentBranchFn(cwd);
566
+ if (extend && !onto?.trim() && !checkedOut) {
567
+ throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) to stack with --extend.");
568
+ }
569
+ const parentBranch = onto?.trim() || (extend && checkedOut ? checkedOut : trunk);
570
+ assertExplicitStacking({
571
+ parent: parentBranch,
572
+ trunk,
573
+ extend,
574
+ stackCommand: `\`mg stack create ${name ?? "<name>"} --onto ${parentBranch} --extend\``,
575
+ trunkCommand: `\`mg stack create ${name ?? "<name>"}\``,
576
+ });
577
+ let startRef = null;
578
+ if (parentBranch === trunk && checkedOut !== trunk) {
579
+ startRef = trunkStartRefFn(trunk, cwd);
580
+ if (!startRef)
581
+ throw new CommandError(`Trunk branch not found: ${trunk}`);
582
+ }
583
+ else if (!branchExistsFn(parentBranch, cwd)) {
584
+ throw new CommandError(`Parent branch not found: ${parentBranch}`);
585
+ }
586
+ else if (parentBranch !== checkedOut) {
587
+ startRef = parentBranch;
588
+ }
589
+ const branch = defaultLayerBranchNameFn(name);
590
+ if (branchExistsFn(branch, cwd)) {
591
+ throw new CommandError(`Branch already exists: ${branch}`);
592
+ }
566
593
  if (trunkFlag?.trim() && !onto?.trim() && parentBranch !== trunk) {
567
594
  console.error(ansi.dim(` note: --trunk ${trunk} does not change the parent; new layer parents onto \`${parentBranch}\`. ` +
568
595
  `Pass \`--onto ${trunk}\` for a fresh stack from trunk.`));
@@ -606,7 +633,10 @@ export async function cmdStackCreate(args, deps = {}) {
606
633
  throw new CommandError(err instanceof Error ? err.message : String(err));
607
634
  }
608
635
  try {
609
- createBranchFromHeadFn(branch, cwd);
636
+ if (startRef)
637
+ createBranchFromFn(branch, startRef, cwd);
638
+ else
639
+ createBranchFromHeadFn(branch, cwd);
610
640
  }
611
641
  catch (err) {
612
642
  throw new CommandError(err instanceof Error ? err.message : String(err));
@@ -637,7 +667,9 @@ export async function cmdStackCreate(args, deps = {}) {
637
667
  return;
638
668
  }
639
669
  const created = [
640
- ansi.brightGreen(` Created ${branch} (onto ${parentBranch})`),
670
+ ansi.brightGreen(parentBranch === meta.trunk
671
+ ? ` Created ${branch}: a new single PR onto ${parentBranch}${startRef ? ` (branched from ${startRef})` : ""}`
672
+ : ` Created ${branch}: a layer stacked on ${parentBranch} (--extend)`),
641
673
  ];
642
674
  if (registeredHit) {
643
675
  created.push(ansi.dim(` extending registered stack ${registeredHit.stackId}` +
@@ -847,6 +879,7 @@ export async function cmdStackSubmit(args, deps = {}) {
847
879
  return;
848
880
  }
849
881
  const lines = published.map((row) => ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${row.created ? "opened" : "exists"}, head ${row.head.slice(0, 12)})`));
882
+ lines.push(ansi.brightGreen(stackShapeLine({ layers: layers.length, base: plans[0]?.githubBase ?? meta.trunk, extendedStack: registeredHits > 0 })));
850
883
  if (publishedStackId)
851
884
  lines.push(ansi.brightGreen(` Registered stack ${publishedStackId}`));
852
885
  lines.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
@@ -987,6 +1020,7 @@ export async function cmdStackSubmit(args, deps = {}) {
987
1020
  const verb = row.created ? "opened" : "exists";
988
1021
  summary.push(ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
989
1022
  }
1023
+ summary.push(ansi.brightGreen(stackShapeLine({ layers: layers.length, base: plans[0]?.githubBase ?? meta.trunk, extendedStack: registeredHits > 0 })));
990
1024
  const adoptedTrunk = data.stack?.trunkBranch;
991
1025
  if (adoptedTrunk?.startsWith("mg-stack-")) {
992
1026
  summary.push(ansi.dim(` Bottom base is now ${adoptedTrunk} (Mergestorm-owned trunk). Leave it.`));
@@ -1003,8 +1037,8 @@ export async function cmdStackSubmit(args, deps = {}) {
1003
1037
  await present("Stack", summary);
1004
1038
  }
1005
1039
  export function parseStackPublishArgs(args) {
1006
- const usage = "usage: mergestorm stack publish [--branch <branch>] [--base <branch>] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]";
1007
- const out = { status: false, asJson: false };
1040
+ const usage = "usage: mergestorm stack publish [--branch <branch>] [--base <branch>] [--extend] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]";
1041
+ const out = { extend: false, status: false, asJson: false };
1008
1042
  const valued = {
1009
1043
  "--branch": "branch",
1010
1044
  "--base": "base",
@@ -1021,6 +1055,10 @@ export function parseStackPublishArgs(args) {
1021
1055
  out.status = true;
1022
1056
  continue;
1023
1057
  }
1058
+ if (a === "--extend") {
1059
+ out.extend = true;
1060
+ continue;
1061
+ }
1024
1062
  if (a === "--auto-land") {
1025
1063
  out.autoLand = parseOnOff(args[++i], "--auto-land", "stack publish");
1026
1064
  continue;
@@ -1066,6 +1104,7 @@ export async function cmdStackPublish(args, deps = {}) {
1066
1104
  ...(parsed.title ? { title: parsed.title } : {}),
1067
1105
  ...(typeof parsed.autoLand === "boolean" ? { autoLand: parsed.autoLand } : {}),
1068
1106
  ...(parsed.replaceHead ? { replaceHead: parsed.replaceHead } : {}),
1107
+ extend: parsed.extend,
1069
1108
  }, publishDeps);
1070
1109
  const watch = openStackWatch(result.stack.id);
1071
1110
  if (parsed.asJson) {
@@ -1076,6 +1115,7 @@ export async function cmdStackPublish(args, deps = {}) {
1076
1115
  await present("Stack", [
1077
1116
  ansi.brightGreen(` ${result.branch} ${moved} at ${result.head.sha.slice(0, 12)} (tree ${result.head.tree.slice(0, 12)})`),
1078
1117
  ansi.brightGreen(` #${result.pr.number} ${result.branch} → ${result.baseBranch} (${result.pr.created ? "opened" : "exists"}) ${result.pr.url}`),
1118
+ ansi.brightGreen(result.stacked ? ` A layer stacked on ${result.baseBranch}.` : ` A single PR onto ${result.baseBranch}.`),
1079
1119
  ansi.brightGreen(` Registered stack ${result.stack.id}`),
1080
1120
  ...stackWatchTextLines(watch).map((line) => ` ${line}`),
1081
1121
  ]);
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const DEFAULT_API = "https://api.mergestorm.ai";
2
2
  export type Config = {
3
3
  apiKey?: string;
4
4
  apiBase?: string;
5
+ ignoreEnv?: boolean;
5
6
  };
6
7
  export declare function mergestormHome(env?: NodeJS.ProcessEnv): string;
7
8
  export declare function configPath(): string;
package/dist/config.js CHANGED
@@ -30,9 +30,12 @@ export async function clearConfig() {
30
30
  await rm(configPath(), { force: true });
31
31
  }
32
32
  export function apiBase(cfg) {
33
- return (process.env.MERGESTORM_API_URL?.trim() || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
33
+ const fromEnv = cfg.ignoreEnv ? undefined : process.env.MERGESTORM_API_URL?.trim();
34
+ return (fromEnv || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
34
35
  }
35
36
  export function resolveApiKey(cfg) {
37
+ if (cfg.ignoreEnv)
38
+ return cfg.apiKey;
36
39
  return process.env.MERGESTORM_API_KEY?.trim() || cfg.apiKey;
37
40
  }
38
41
  /** Short display form of a stored key (prefix only). */
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;
@@ -15,6 +15,8 @@ export declare function discoverTrunk(cwd?: string): string;
15
15
  export declare function branchExists(name: string, cwd?: string): boolean;
16
16
  export declare function deleteBranch(name: string, cwd?: string): void;
17
17
  export declare function createBranchFromHead(name: string, cwd?: string): void;
18
+ export declare function createBranchFrom(name: string, start: string, cwd?: string): void;
19
+ export declare function trunkStartRef(trunk: string, cwd?: string): string | null;
18
20
  /** Slugify a free-form name into a safe branch segment. */
19
21
  export declare function slugifyBranchName(raw: string): string;
20
22
  export declare function defaultLayerBranchName(explicit?: string): string;
package/dist/git-stack.js CHANGED
@@ -78,6 +78,21 @@ export function deleteBranch(name, cwd = process.cwd()) {
78
78
  export function createBranchFromHead(name, cwd = process.cwd()) {
79
79
  git(["checkout", "-b", name], cwd);
80
80
  }
81
+ export function createBranchFrom(name, start, cwd = process.cwd()) {
82
+ git(["checkout", "-b", name, start], cwd);
83
+ }
84
+ export function trunkStartRef(trunk, cwd = process.cwd()) {
85
+ for (const ref of [`refs/remotes/origin/${trunk}`, `refs/heads/${trunk}`]) {
86
+ try {
87
+ git(["rev-parse", "--verify", "--quiet", ref], cwd);
88
+ return ref === `refs/heads/${trunk}` ? trunk : `origin/${trunk}`;
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ }
94
+ return null;
95
+ }
81
96
  /** Slugify a free-form name into a safe branch segment. */
82
97
  export function slugifyBranchName(raw) {
83
98
  const s = raw
@@ -1,5 +1,6 @@
1
1
  import { getStackPublication, submitStackPublication, type StackPublication, type StackPublicationChange, type StackPublicationCommit, type StackPublicationIdentity, type StackPublicationStatus } from "./api.js";
2
2
  import { loadConfig, type Config } from "./config.js";
3
+ import { discoverTrunk } from "./git-stack.js";
3
4
  export declare const SERVER_PUBLISH_MAX_COMMITS = 20;
4
5
  export declare const SERVER_PUBLISH_MAX_CHANGES = 500;
5
6
  export declare const SERVER_PUBLISH_MAX_FILE_BYTES: number;
@@ -39,6 +40,7 @@ export type ServerPublishDeps = {
39
40
  read?: GitRead;
40
41
  getStackPublication?: typeof getStackPublication;
41
42
  submitStackPublication?: typeof submitStackPublication;
43
+ assertBase?: (base: string) => void;
42
44
  };
43
45
  export type ServerPublishResult = StackPublication & {
44
46
  baseBranch: string;
@@ -54,17 +56,28 @@ export type WorkingBranchInput = {
54
56
  body?: string;
55
57
  autoLand?: boolean;
56
58
  replaceHead?: string;
59
+ extend?: boolean;
57
60
  cfg?: Config;
58
61
  };
59
62
  export type WorkingBranchDeps = ServerPublishDeps & {
60
63
  publishBranchServerSide?: typeof publishBranchServerSide;
61
64
  loadConfig?: typeof loadConfig;
65
+ discoverTrunk?: typeof discoverTrunk;
62
66
  };
67
+ export declare function assertExplicitStacking(input: {
68
+ parent: string;
69
+ trunk: string;
70
+ extend: boolean;
71
+ stackCommand: string;
72
+ trunkCommand: string;
73
+ }): void;
63
74
  export declare function workingBranchTarget(input: WorkingBranchInput): {
64
75
  cwd: string;
65
76
  owner: string;
66
77
  repo: string;
67
78
  branch: string;
68
79
  };
69
- export declare function publishWorkingBranch(input: WorkingBranchInput, deps?: WorkingBranchDeps): Promise<ServerPublishResult>;
80
+ export declare function publishWorkingBranch(input: WorkingBranchInput, deps?: WorkingBranchDeps): Promise<ServerPublishResult & {
81
+ stacked: boolean;
82
+ }>;
70
83
  export declare function workingBranchPublication(input: WorkingBranchInput, deps?: Pick<WorkingBranchDeps, "getStackPublication" | "loadConfig">): Promise<StackPublicationStatus>;
@@ -180,6 +180,7 @@ export async function publishBranchServerSide(input, deps = {}) {
180
180
  throw new CommandError(`Branch missing locally: ${branch}`);
181
181
  const remote = await status(cfg, { owner, repo, branch });
182
182
  const baseBranch = remote.pr?.base ?? input.baseBranch;
183
+ deps.assertBase?.(baseBranch);
183
184
  const localBase = input.localBaseRef ??
184
185
  (resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
185
186
  if (!resolves(read, localBase, cwd))
@@ -229,6 +230,13 @@ export async function publishBranchServerSide(input, deps = {}) {
229
230
  }
230
231
  return { ...publication, baseBranch };
231
232
  }
233
+ export function assertExplicitStacking(input) {
234
+ if (input.extend || input.parent === input.trunk)
235
+ return;
236
+ throw new CommandError(`\`${input.parent}\` is not trunk (\`${input.trunk}\`), and stacking onto it needs --extend.\n` +
237
+ `• Stack onto \`${input.parent}\`: ${input.stackCommand}\n` +
238
+ `• New stack from \`${input.trunk}\`: ${input.trunkCommand}`, 1, "implicit_stack");
239
+ }
232
240
  export function workingBranchTarget(input) {
233
241
  const cwd = input.cwd ?? process.cwd();
234
242
  const origin = input.owner && input.repo ? { owner: input.owner, repo: input.repo } : parseGithubOriginRepo(cwd);
@@ -242,17 +250,36 @@ export function workingBranchTarget(input) {
242
250
  }
243
251
  export async function publishWorkingBranch(input, deps = {}) {
244
252
  const { cwd, owner, repo, branch } = workingBranchTarget(input);
245
- let baseBranch = input.base;
253
+ const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, discoverTrunk: findTrunk = discoverTrunk, ...publishDeps } = deps;
254
+ let trunk = null;
255
+ let trunkError = null;
256
+ try {
257
+ trunk = findTrunk(cwd);
258
+ }
259
+ catch (err) {
260
+ trunkError = err;
261
+ }
262
+ const trunkMessage = trunkError instanceof Error ? trunkError.message : String(trunkError);
263
+ const baseBranch = input.base ?? trunk;
246
264
  if (!baseBranch) {
247
- try {
248
- baseBranch = discoverTrunk(cwd);
265
+ throw new CommandError(`${trunkMessage} Name the base branch.`);
266
+ }
267
+ const assertBase = (parent) => {
268
+ if (trunk) {
269
+ assertExplicitStacking({
270
+ parent,
271
+ trunk,
272
+ extend: input.extend === true,
273
+ stackCommand: `\`mg stack publish --base ${parent} --extend\` (MCP stack_publish: extend true)`,
274
+ trunkCommand: "`mg stack publish` with no --base (MCP stack_publish: no base)",
275
+ });
249
276
  }
250
- catch (err) {
251
- throw new CommandError(`${err instanceof Error ? err.message : String(err)} Name the base branch.`);
277
+ else if (input.extend !== true) {
278
+ throw new CommandError(`${trunkMessage} \`${parent}\` cannot be verified as trunk, so stacking onto it needs --extend.`, 1, "implicit_stack");
252
279
  }
253
- }
254
- const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, ...publishDeps } = deps;
255
- return publish({
280
+ };
281
+ assertBase(baseBranch);
282
+ const result = await publish({
256
283
  cfg: input.cfg ?? (await load()),
257
284
  cwd,
258
285
  owner,
@@ -263,7 +290,8 @@ export async function publishWorkingBranch(input, deps = {}) {
263
290
  body: input.body ?? buildPrBodyFromCommit(tipCommitMessage(branch, cwd)),
264
291
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
265
292
  ...(input.replaceHead ? { replaceHead: input.replaceHead } : {}),
266
- }, publishDeps);
293
+ }, { ...publishDeps, assertBase });
294
+ return { ...result, stacked: !trunk || result.baseBranch !== trunk };
267
295
  }
268
296
  export async function workingBranchPublication(input, deps = {}) {
269
297
  const { owner, repo, branch } = workingBranchTarget(input);
@@ -170,6 +170,8 @@ export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no rev
170
170
  export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
171
171
  export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
172
172
  export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
173
+ export declare const CYCLONE_NOT_RUN_ATTENTION_MS: number;
174
+ export declare const CYCLONE_NOT_RUN_REASON = "not_run";
173
175
  export declare function cycloneStatusWords(layer: Pick<StackLayerDto, "cycloneStatus" | "cycloneHandoff" | "headSha">): string | null;
174
176
  export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
175
177
  export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
@@ -216,7 +218,7 @@ export declare function restackFailedRepairSteps(branch: string, liveParent: str
216
218
  detail?: string | null;
217
219
  }): string;
218
220
  export declare function agentsCannotClear(reason: StackBlockerReason, stack?: StackDto, layer?: StackLayerDto): boolean;
219
- export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
221
+ export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null, nowMs?: number): StackRepairHint | null;
220
222
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
221
223
  /** Pair-gate attention plus live hard blocks on every other open layer. */
222
224
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
@@ -72,10 +72,26 @@ function vortexFindingsRestAtHead(layer) {
72
72
  function vortexChangesRequestedAtHead(layer) {
73
73
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
74
74
  }
75
- function cycloneHandoffAtHead(stack, layer) {
75
+ export const CYCLONE_NOT_RUN_ATTENTION_MS = 30 * 60_000;
76
+ export const CYCLONE_NOT_RUN_REASON = "not_run";
77
+ function cycloneNotRunAtHead(layer, nowMs) {
78
+ const review = layer.vortexReview;
79
+ if (layer.cycloneFindingsTakenAtHead !== false || layer.vortexStatus !== "findings")
80
+ return false;
81
+ if (review?.status !== "done" || !sameHead(review.head_sha, layer.headSha))
82
+ return false;
83
+ const settledMs = Date.parse(review.settled_at ?? "");
84
+ return Number.isFinite(settledMs) && nowMs - settledMs >= CYCLONE_NOT_RUN_ATTENTION_MS;
85
+ }
86
+ function cycloneHandoffAtHead(stack, layer, nowMs) {
76
87
  if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
77
88
  return layer.cycloneHandoff.reason;
78
- return stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
89
+ if (layer.cycloneStatus === "failed")
90
+ return "failed";
91
+ const off = stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
92
+ if (off)
93
+ return off;
94
+ return cycloneNotRunAtHead(layer, nowMs) ? CYCLONE_NOT_RUN_REASON : null;
79
95
  }
80
96
  const CYCLONE_HANDOFF_WORDS = {
81
97
  failed: "Cyclone's patch failed, so it pushed nothing",
@@ -93,6 +109,11 @@ const CYCLONE_HANDOFF_WORDS = {
93
109
  unsupported_language: "the findings are in a language Cyclone does not patch",
94
110
  policy_owner_changed: "the stack's owner changed while the patch was queued",
95
111
  patch_job_lost: "Cyclone's patch job was lost before it finished",
112
+ pr_state_unavailable: "Cyclone could not read the PR's state, so it pushed nothing",
113
+ left_for_human: "Cyclone did not patch these findings and pushed nothing",
114
+ hold: "the review told Cyclone to hold, so it did not patch these findings",
115
+ vortex_patch_cap: "Cyclone already patched this PR as many times as it may",
116
+ [CYCLONE_NOT_RUN_REASON]: `Cyclone has not run on this head in the ${CYCLONE_NOT_RUN_ATTENTION_MS / 60_000} minutes since Vortex posted these findings, and nothing is patching it`,
96
117
  conflict_needs_person: "Cyclone could not resolve the conflict and left it for a person",
97
118
  conflict_unresolved: "Cyclone's conflict resolution failed",
98
119
  auto_resolve_daily_cap: "Cyclone already resolved conflicts on this PR twice today, its daily cap",
@@ -179,6 +200,10 @@ const CYCLONE_HELD_FINDINGS_REASONS = [
179
200
  "pr_state_unavailable",
180
201
  "vortex_patch_cap",
181
202
  "patch_job_lost",
203
+ "unsupported_language",
204
+ "credits_exhausted",
205
+ "no_billing",
206
+ "policy_owner_changed",
182
207
  ];
183
208
  function vortexImportantFindingsHeldAtHead(layer) {
184
209
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "reviewed" &&
@@ -189,13 +214,13 @@ function seamApprovedAtHead(stack, layer) {
189
214
  const member = (stack.unit?.members ?? []).find((entry) => entry.prNumber === layer.prNumber);
190
215
  return member?.seamState?.trim().toLowerCase() === "approved" && sameHead(member.seamReviewedSha, layer.headSha);
191
216
  }
192
- function vortexFindingsLeftForPerson(stack, layer) {
217
+ function vortexFindingsLeftForPerson(stack, layer, nowMs) {
193
218
  if (layerAgentsBusy(layer).cyclone)
194
219
  return false;
195
220
  if (seamApprovedAtHead(stack, layer))
196
221
  return false;
197
222
  return vortexImportantFindingsHeldAtHead(layer) ||
198
- (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer) !== null);
223
+ (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer, nowMs) !== null);
199
224
  }
200
225
  function quotaSkippedReview(layer) {
201
226
  const review = layer.vortexReview;
@@ -310,7 +335,7 @@ function headChecksRerunning(layer) {
310
335
  return layer.ciStatus === "pending" && !!checks && checks.failure === 0 && checks.pending > 0 && checks.cancelledOnly !== true;
311
336
  }
312
337
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
313
- function layerAttention(stack, layer, queue, cursor) {
338
+ function layerAttention(stack, layer, queue, cursor, nowMs) {
314
339
  const none = { blocker: null, reason: null, bounceKind: null };
315
340
  if (stack.archivedAt)
316
341
  return none;
@@ -363,7 +388,7 @@ function layerAttention(stack, layer, queue, cursor) {
363
388
  if (landing && !landGateIsPending(landing))
364
389
  return hardBlock(landing, "land_gate");
365
390
  }
366
- if (vortexFindingsLeftForPerson(stack, layer))
391
+ if (vortexFindingsLeftForPerson(stack, layer, nowMs))
367
392
  return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
368
393
  }
369
394
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
@@ -682,7 +707,7 @@ export function agentsCannotClear(reason, stack, layer) {
682
707
  return false;
683
708
  return AGENTS_CANNOT_CLEAR.includes(reason);
684
709
  }
685
- export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
710
+ export function stackRepair(stack, attention, layer, bounce, queueWait = null, nowMs = Date.now()) {
686
711
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
687
712
  return null;
688
713
  const reason = attention.reason;
@@ -866,7 +891,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
866
891
  }
867
892
  if (reason === "vortex_findings_need_person") {
868
893
  const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
869
- const handoff = cycloneHandoffAtHead(stack, layer);
894
+ const handoff = cycloneHandoffAtHead(stack, layer, nowMs);
870
895
  return {
871
896
  kind: "vortex_findings",
872
897
  prNumber: layer.prNumber,
@@ -874,7 +899,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
874
899
  branch: layer.branch,
875
900
  handoff,
876
901
  findings,
877
- steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
902
+ steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them undone${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, so nothing will patch them and Auto land waits. You fix them: read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Dismiss a finding you verified is wrong yourself: mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> --finding <id> --reason <why> --evidence <link>. Ask the human only for a product or design decision. Do not retarget the PR base.`,
878
903
  };
879
904
  }
880
905
  if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
@@ -964,7 +989,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
964
989
  for (const layer of open) {
965
990
  const selected = layer.prNumber === candidate?.prNumber;
966
991
  const inScope = selected || !candidate || layer.position > candidate.position;
967
- let result = layerAttention(stack, layer, selected ? queue : [], cursor);
992
+ let result = layerAttention(stack, layer, selected ? queue : [], cursor, nowMs);
968
993
  let disarmed;
969
994
  if (!result.blocker && selected) {
970
995
  disarmed = disarmingBounce(stack, queue);
@@ -1071,7 +1096,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1071
1096
  if (busy.length && attention && classified && agentsCannotClear(classified.reason, stack, attentionLayer)) {
1072
1097
  held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1073
1098
  shownIssues = [attention, ...issues];
1074
- repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait);
1099
+ repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait, nowMs);
1075
1100
  attention = null;
1076
1101
  bounce = undefined;
1077
1102
  }
@@ -1081,7 +1106,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1081
1106
  bounce = undefined;
1082
1107
  }
1083
1108
  else {
1084
- repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait);
1109
+ repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait, nowMs);
1085
1110
  }
1086
1111
  if (!attention) {
1087
1112
  const next = found.find((item) => !layerBusy(item.layer));
@@ -1089,7 +1114,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1089
1114
  attention = next.issue;
1090
1115
  repair = next.repair !== undefined
1091
1116
  ? next.repair
1092
- : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait);
1117
+ : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait, nowMs);
1093
1118
  bounce = next.bounce;
1094
1119
  if (next.layer)
1095
1120
  agents = layerAgents(next.layer);
@@ -59,6 +59,7 @@ export type StackVortexReviewView = {
59
59
  phase: string | null;
60
60
  /** Earliest live job start while `reviewing`; null when lease-only. */
61
61
  started_at: string | null;
62
+ settled_at?: string | null;
62
63
  /** Stop control visibility: true only while a pass can actually be cancelled. */
63
64
  stoppable: boolean;
64
65
  /** Which table spoke: the authority row, a live job, a lease, or nothing. */
@@ -225,6 +226,7 @@ export type StackLayerDto = {
225
226
  cycloneConflictPendingSince?: string | null;
226
227
  cycloneCiHandoff?: StackCycloneHandoff | null;
227
228
  cycloneCiPendingSince?: string | null;
229
+ cycloneFindingsTakenAtHead?: boolean;
228
230
  conflictDetail: string | null;
229
231
  restackError?: RestackError | null;
230
232
  lastRestackedSha: string | null;
package/dist/ui/banner.js CHANGED
@@ -224,6 +224,9 @@ const WHATS_NEW = {
224
224
  "0.3.62": [
225
225
  "Watch wakes for a blocker on any layer",
226
226
  ],
227
+ "0.3.63": [
228
+ "Stack create starts a new stack unless --extend",
229
+ ],
227
230
  };
228
231
  function densityFromVariant(variant) {
229
232
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.62",
3
+ "version": "0.3.63",
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",
@@ -20,7 +20,14 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": "./dist/cli.js",
23
- "./client": "./dist/client/index.js"
23
+ "./client": {
24
+ "mergestorm-source": "./src/client/index.ts",
25
+ "default": "./dist/client/index.js"
26
+ },
27
+ "./skill": {
28
+ "mergestorm-source": "./src/commands/skill.ts",
29
+ "default": "./dist/commands/skill.js"
30
+ }
24
31
  },
25
32
  "homepage": "https://mergestorm.ai/docs#review-jobs-api",
26
33
  "repository": {
package/skill/SKILL.md CHANGED
@@ -48,7 +48,7 @@ Cursor `~/.cursor/mcp.json`:
48
48
 
49
49
  ## Preconditions
50
50
 
51
- 1. Prefer the `mergestorm` MCP server. Call `whoami`. If MCP is missing, require `mg` on PATH with `mg whoami --json` exit 0. If neither is available, stop and print the install one-liner above.
51
+ 1. Prefer the `mergestorm` MCP server. Call `whoami`. If MCP is missing, require `mg` on PATH with `mg whoami --json` exit 0. If neither is available, stop and print the install one-liner above. A hosted connection to the Mergestorm MCP server, the kind a chat agent uses, has no `review_submit`: it cannot read your checkout. With only that connection, tell the human that this review runs where the branch is checked out, with `mg` or a local MCP server, and stop. Do not install `mg` yourself to get around it.
52
52
  2. Call `credits` (or `mg credits --json`). If remaining standard credits are 0, say so and stop.
53
53
 
54
54
  Ignored bot authors live in Settings → Vortex ("Ignore bot PR authors"), not Agents. Local `review_submit` is a manual review and is not an ignore-list skip.
@@ -5,7 +5,9 @@ description: Watch stacks explicitly named by the human while Auto land is on, p
5
5
 
6
6
  # Mergestorm bounce watch
7
7
 
8
- Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch, and the `mg stack watch` CLI command to wait in the background. Auto land owns landing.
8
+ Use only these shipped Mergestorm MCP tools (plus `cyclone_start` and `cyclone_job` for the hosted handoff below): `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch, and, where the `mg` CLI is installed, the `mg stack watch` command to wait in the background. Auto land owns landing.
9
+
10
+ A chat agent that reaches Mergestorm only through the hosted MCP tools has no checkout it can push and no `mg`. It hands each fix a commit would make to Cyclone: `cyclone_start` with task `resolve_conflicts` for a merge conflict, `fix_ci` for a failing check, or `fix_findings` for Vortex findings, then `cyclone_job` with its `dispatch_id` until the job is pushed, completed or failed. It waits with `stack_wait` and does not install `mg`.
9
11
 
10
12
  ## Who fixes what
11
13
 
@@ -17,7 +19,7 @@ A stack watch is done only when the stack is landed, or when the human takes it
17
19
 
18
20
  ## Waiting in the background
19
21
 
20
- The CLI watcher monitors the stack but cannot wake an agent by itself. Use the background handoff only when the host has a confirmed notification that resumes this task. A confirmed notification is a host feature you can name that turns the command's exit or matching output into your next turn; a process left running, or output written to a log or terminal you do not read, is not one. Without one, keep using `stack_wait` with `watch.next.args`, or tell the human that automatic follow-up is unavailable before ending the turn. Do not claim the task will resume just because a shell process is running. Do not subscribe to GitHub events. With a confirmed notification:
22
+ This section needs the `mg` CLI where you run commands. Without it, skip this section and keep calling `stack_wait` with `watch.next.args`. The CLI watcher monitors the stack but cannot wake an agent by itself. Use the background handoff only when the host has a confirmed notification that resumes this task. A confirmed notification is a host feature you can name that turns the command's exit or matching output into your next turn; a process left running, or output written to a log or terminal you do not read, is not one. Without one, keep using `stack_wait` with `watch.next.args`, or tell the human that automatic follow-up is unavailable before ending the turn. Do not claim the task will resume just because a shell process is running. Do not subscribe to GitHub events. With a confirmed notification:
21
23
 
22
24
  1. Run `mg stack watch <stack-id>` as a background command (add `--head <sha>` right after you pushed that SHA) with a notification on output matching `MS-WATCH (ATTENTION|LANDED)`, then end your turn. The watcher runs the same stack wait against the Mergestorm API in slices of at most 300s, carries the cursor (`enrolled_head_sha`, `after_finished_at`, `bounce_id`) from one slice to the next, and prints nothing while the stack is `waiting` or `in_progress`, including `busy[]` holds and `actAfter: "agents_idle"`. It spends no model tokens while it waits. Ending your turn while it runs is not ending the task. Every `watch.next` carries this command as `watch.next.background`.
23
25
  2. It prints one line and exits for one of three reasons:
@@ -48,7 +50,7 @@ A stack whose layers are all merged into `mg-stack-N` but has no open land PR re
48
50
  3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending` or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. Auto land records why it is waiting in the stack's `autoLandWait` (`reason`, `prNumber`, `headSha`, `since`), and the watch returns `attention` on that PR when the wait cannot clear on its own: a failing check Auto land reads live on GitHub while the ledger may still show CI green (blocker `CI failed — Auto land reads a failing check on GitHub at this head`, `repair` kind `ci_failure`); a check pending for 45 minutes (`repair` kind `ci_pending`: find the check that never finished and re-run it, or tell the human when no workflow reports it); no approving Vortex review for 30 minutes with no Vortex run working (`repair` kind `vortex_not_green`: fix or dismiss the findings, or tell the human when no review exists at the head); and a promote GitHub refused 3 times with `merge_failed`, after which Auto land stops retrying that head (`repair` kind `promote_failed`: fix the cause on the PR and push a new head, or tell the human when the cause is outside the PR; never toggle Auto land yourself for it). The watch also returns `attention` when GitHub refuses to let Mergestorm read the head's check runs or commit statuses (blocker `Mergestorm cannot read ... on this head`, `repair` kind `ci_unreadable`: do not patch or push; tell the human to grant the Mergestorm GitHub App the permission the repair names), and when any other wait reason has held for 45 minutes, a land gate left pending or running on the land PR included (blocker `Auto land has waited <N>m: <reason in plain words>`, `repair` kind `auto_land_waiting`: read `stack_status`, fix what a commit on the named branch can fix, otherwise tell the human what Auto land waits for and since when). A merge queue entry that has sat queued for 45 minutes with nothing working on it or ahead of it returns `attention` with the blocker `Merge queue stuck: ...` and a `repair` whose kind is queue_stalled: do not patch or push; tell the human. A Vortex review skipped because the PR author is on the ignored authors list, or because reviews are turned off for the repository, returns `attention` at once with a `repair` of kind `vortex_skipped`; a Vortex retry that is scheduled (`Vortex hit a temporary error on this head and will retry by itself`) is a wait listed in `issues[]`, never `attention`. `Review failed` carries a `repair` of kind `vortex_failed`: comment `@mergestorm-vortex review` on the PR or push a new commit, and tell the human if it fails again. A PR open on `mg-stack-N` that is not a layer of the stack returns `attention` at once with the blocker `The land PR cannot be queued: ...` and a `repair` of kind `members_remaining`: do not patch or push; close, retarget or adopt that PR (a draft or fork PR cannot be adopted). A layer whose live head has a Vortex verdict that requests changes after Cyclone left those findings for a person (a hold directive, the Vortex patch cap, auto-patch off, no billing, a batch it gave up on, or a PR state it could not read) returns `attention` with the blocker `Vortex findings need a person` and a `repair` of kind `vortex_findings` naming the Cyclone reason (`handoff`) and where the review is (`findings`): verify each finding against the checkout, fix the real ones with the smallest patch and an ordinary push, and tell the human about the ones you judge wrong. `repair` does not replace the checks below. 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; the only policy change you make is the re-arm in "Re-arm after a bounce". A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. 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 blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
49
51
  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. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer. Cyclone patches Vortex findings on a land PR when auto-patch is on for that stack; every other land PR blocker, a conflict with main or failing CI included, is yours: when `busy[]` shows no Cyclone run on it, fix it with the smallest patch on `mg-stack-N` and push with an ordinary push, never a force push; while Cyclone is patching findings on it the envelope stays `in_progress` with `busy[]`, so wait. For a conflict, prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Do not click Dashboard Continue yourself: Continue is a human action, and you relay it to the human only when step 5 or a `repair` names it. 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-*`.
50
52
  5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. 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. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — IU <n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Seam review stuck in reviewing, no review running`, `Seam re-review pending, no review running`, or `Seam verdict is for an older head, no review running` (repair kind `seam_review_stuck`), do not patch or push; tell the human: no seam review is queued or running for that PR's live head, and clicking Continue on the stack re-queues it (a new pushed head also does). On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
51
- 6. Recheck Auto land (off is expected only after a disarming bounce you are handling) 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.
53
+ 6. Recheck Auto land (off is expected only after a disarming bounce you are handling) and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Also call `queue_status` with the `stack_id`: while an entry of the stack is `queued`, `running`, or `waiting`, do not push. The queue is verifying the diff that was reviewed when it was queued; a push that changes that diff bounces the entry `head_moved` and stops the batch it is in, which re-runs CI for every PR in that batch. Do not cancel the entry. Keep the fix on a local branch and keep watching: when the entry bounces, push the fix to this PR; when the stack lands, open the fix as a follow-up PR (`mg stack create <name> --onto main`, then `mg stack submit`) and tell the human its number. 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.
52
54
  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 (in the background: `mg stack watch <stack-id> --head <pushed-sha>`). 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 anything. If the bounce you handled turned Auto land off, turn it back on as "Re-arm after a bounce" allows; if that section does not allow it, report and stop.
53
55
 
54
56
  ## Re-arm after a bounce
@@ -19,10 +19,13 @@ mg skill install --claude --cursor --agents
19
19
 
20
20
  ## Preconditions (in order)
21
21
 
22
+ A hosted-only agent (no checkout of the PR head; see 5) checks 1, then follows 5 instead of 2 to 4: those gates are for the local patching loop it does not run, and the `mg login` sign-in in 4 needs a shell it does not have.
23
+
22
24
  1. There is an already-open GitHub PR on a Vortex-monitored repo. No PR, wrong skill.
23
25
  2. Auto-patch (Cyclone) must be off for this PR so you are the only patcher. Do not take anyone's word for it: the settings API and the PR's stack are the source of truth, and step 1 of the loop verifies them before anything else runs. Cyclone is off for the PR when its adopted stack has `autoPatchOverride` `false`, or when that override is unset and the account `auto_patch_enabled` is `false`.
24
26
  3. The Bearer key and the Cyclone GitHub App install must be the same Mergestorm account. `stack_list` / `stack_status` expose `keyUserId`, `cycloneInstallUserId`, and `cycloneOwnerMatch`. Cyclone apply always uses the install account, not the key. If those identities differ, your `settings_get` mute reading is about the wrong person.
25
27
  4. `whoami` works on the `mergestorm` MCP server (or `mg whoami --json` exits 0). Otherwise stop and ask for install, or sign in yourself: run `mg login --start`, send the person the link it prints, and after they approve it run `mg login --finish`.
28
+ 5. You have a checkout of the PR head that you can push. A chat agent that reaches Mergestorm only through the hosted MCP tools, with no such checkout, does not run this loop: Cyclone patches the PR. Call `cyclone_start` with task `fix_findings` for the PR, then call `cyclone_job` with its `dispatch_id` until the job is pushed, completed or failed, and tell the human what it pushed.
26
29
 
27
30
  ## Loop (in order)
28
31
 
@@ -55,9 +58,10 @@ mg skill install --claude --cursor --agents
55
58
  - A refusal (stale_head, review_mismatch, unknown_finding, forbidden) means nothing was written. Re-read the pass at the live head; do not retry with another review's ids.
56
59
  - The seam gate clears only when every finding of that seam review is dismissed. Once every Vortex Blocker (error finding) at the live head is dismissed (every Core review merged into that head, not only the one you name), the Vortex gate clears at that head; while any is open, or a Vortex review of the head is still running or incomplete, it stays shut and `gate.vortex.message` says why. The result's `gate` says whether either cleared; CI and Auto land policy are unchanged. For a stack PR, follow `watch.next` (the next `stack_wait` call).
57
60
  6. Patch on the PR head branch only.
58
- 7. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
59
- 8. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. If the last envelope you acted on has that same `head_sha` (no new commit, for example a re-review requested on the same head), also pass `after_pass` set to that envelope's saved `pass`, so the wait cannot hand you that attempt again. For a freshly pushed SHA omit `after_pass`; its first pass is 1. Keep `after_sha` and `after_pass` unchanged across 45s timeout retries; never replace `after_pass` with the `pass` of an in-progress envelope. `pass` reads one exact attempt; `timeout_s` 0 reads once without waiting. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
60
- 9. Repeat from step 4 until a stop rule fires.
61
+ 7. If the PR is a layer of a stack, call `queue_status` with that `stack_id` before you push. While an entry of the stack is `queued`, `running`, or `waiting`, do not push: the queue is verifying the reviewed diff, and a push that changes it bounces the entry `head_moved` and stops the batch it is in, which re-runs CI for every PR in that batch. Do not cancel the entry. Keep the fix on a local branch and wait for the entry to finish: when it bounces, continue with the push; when it lands, open the fix as a follow-up PR (`mg stack create <name> --onto main`, then `mg stack submit`), tell the human its number, and stop this loop. Without a live entry, continue. Auto land needs no change: a push before it queues the PR only restarts its wait on the new head.
62
+ 8. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
63
+ 9. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. If the last envelope you acted on has that same `head_sha` (no new commit, for example a re-review requested on the same head), also pass `after_pass` set to that envelope's saved `pass`, so the wait cannot hand you that attempt again. For a freshly pushed SHA omit `after_pass`; its first pass is 1. Keep `after_sha` and `after_pass` unchanged across 45s timeout retries; never replace `after_pass` with the `pass` of an in-progress envelope. `pass` reads one exact attempt; `timeout_s` 0 reads once without waiting. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
64
+ 10. Repeat from step 4 until a stop rule fires.
61
65
 
62
66
  ## Stop rules
63
67