mergestorm 0.3.9 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +67 -24
  2. package/dist/api.d.ts +106 -40
  3. package/dist/api.js +245 -9
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/branches.d.ts +6 -2
  7. package/dist/commands/branches.js +29 -14
  8. package/dist/commands/browse.d.ts +39 -0
  9. package/dist/commands/browse.js +259 -0
  10. package/dist/commands/credits.js +30 -12
  11. package/dist/commands/help.d.ts +5 -0
  12. package/dist/commands/help.js +93 -0
  13. package/dist/commands/jobs.d.ts +11 -2
  14. package/dist/commands/jobs.js +64 -15
  15. package/dist/commands/login.js +32 -10
  16. package/dist/commands/logout.js +2 -1
  17. package/dist/commands/pr.d.ts +22 -0
  18. package/dist/commands/pr.js +127 -0
  19. package/dist/commands/queue.d.ts +12 -0
  20. package/dist/commands/queue.js +160 -0
  21. package/dist/commands/registry.d.ts +1 -1
  22. package/dist/commands/registry.js +115 -33
  23. package/dist/commands/review-client.d.ts +106 -0
  24. package/dist/commands/review-client.js +399 -0
  25. package/dist/commands/review.d.ts +36 -6
  26. package/dist/commands/review.js +395 -145
  27. package/dist/commands/settings.d.ts +17 -0
  28. package/dist/commands/settings.js +89 -0
  29. package/dist/commands/skill.d.ts +20 -0
  30. package/dist/commands/skill.js +145 -0
  31. package/dist/commands/stack.d.ts +36 -2
  32. package/dist/commands/stack.js +341 -113
  33. package/dist/commands/status.d.ts +18 -2
  34. package/dist/commands/status.js +236 -21
  35. package/dist/commands/whoami.d.ts +3 -1
  36. package/dist/commands/whoami.js +21 -7
  37. package/dist/errors.d.ts +18 -2
  38. package/dist/errors.js +22 -1
  39. package/dist/git-stack.d.ts +2 -0
  40. package/dist/git-stack.js +4 -0
  41. package/dist/git.d.ts +12 -1
  42. package/dist/git.js +52 -7
  43. package/dist/shell.d.ts +1 -1
  44. package/dist/shell.js +39 -28
  45. package/dist/stack-dto.d.ts +266 -0
  46. package/dist/stack-dto.js +7 -0
  47. package/dist/submit-pr-base.d.ts +31 -0
  48. package/dist/submit-pr-base.js +69 -0
  49. package/dist/ui/ansi.d.ts +2 -0
  50. package/dist/ui/ansi.js +2 -0
  51. package/dist/ui/banner.d.ts +34 -2
  52. package/dist/ui/banner.js +170 -79
  53. package/dist/ui/box.d.ts +13 -9
  54. package/dist/ui/box.js +28 -44
  55. package/dist/ui/findings.d.ts +4 -1
  56. package/dist/ui/findings.js +14 -7
  57. package/dist/ui/job-envelope.d.ts +68 -0
  58. package/dist/ui/job-envelope.js +35 -0
  59. package/dist/ui/line-tabs.d.ts +27 -0
  60. package/dist/ui/line-tabs.js +170 -0
  61. package/dist/ui/logo.d.ts +10 -2
  62. package/dist/ui/logo.js +13 -9
  63. package/dist/ui/present.d.ts +10 -0
  64. package/dist/ui/present.js +13 -0
  65. package/dist/ui/prompt.d.ts +148 -0
  66. package/dist/ui/prompt.js +433 -71
  67. package/dist/ui/select.js +1 -1
  68. package/dist/ui/tabs.d.ts +125 -0
  69. package/dist/ui/tabs.js +480 -0
  70. package/dist/ui/usage.d.ts +29 -1
  71. package/dist/ui/usage.js +71 -2
  72. package/dist/ui/width.d.ts +26 -0
  73. package/dist/ui/width.js +262 -0
  74. package/package.json +6 -1
  75. package/skill/SKILL.md +98 -0
  76. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
@@ -1,26 +1,77 @@
1
- import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoPromote, } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, landNextStack, listStacks, restackStack, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
5
  import { parseGithubOriginRepo } from "../git.js";
6
- import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
6
+ import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
7
7
  import { buildPrBodyFromCommit } from "../pr-body.js";
8
8
  import { gitTopLevel } from "../repo-identity.js";
9
9
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, } from "../stack-meta.js";
10
+ import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
10
11
  import { ansi } from "../ui/ansi.js";
12
+ import { runLineTabsBrowser } from "../ui/line-tabs.js";
13
+ import { present } from "../ui/present.js";
14
+ import { canBrowse } from "./browse.js";
15
+ import { openHelpBrowser } from "./help.js";
11
16
  const STACK_USAGE = `usage:
12
- mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
13
- mergestorm stack submit [--json]
17
+ mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--json]
18
+ mergestorm stack submit [--extend] [--json]
14
19
  mergestorm stack reset --force
15
20
  mergestorm stack list [--json]
16
21
  mergestorm stack adopt <owner/repo>#<pr>
17
22
  mergestorm stack restack <stack-id> [--json]
18
23
  mergestorm stack land <stack-id> [--json]
19
- mergestorm stack auto-promote on|off <stack-id> [--json]
20
24
 
21
25
  stack submit opens PRs with a body generated from each layer tip commit.
22
- stack land promotes into the review unit when one exists; otherwise lands
23
- the bottom open PR (unit-less stacks).`;
26
+ Layers 1–2 use the git parent as GitHub base. Layer 3+ opens onto the
27
+ review-unit park freeze (mg-park-*) so adopt does not retarget after open.
28
+ Parenting onto a branch that already belongs to a registered (submitted)
29
+ stack requires --extend on create and submit — otherwise mg starts a new
30
+ stack from trunk. stack land promotes into the review unit when one exists;
31
+ otherwise lands the bottom open PR (unit-less stacks).`;
32
+ /** Cap the registered-stack guard's API probe so it cannot stall local authoring. */
33
+ const STACK_GUARD_TIMEOUT_MS = 8_000;
34
+ /** Find a registered stack layer whose branch matches `parentBranch`. */
35
+ export function findRegisteredParent(stacks, parentBranch, owner, repo) {
36
+ const want = parentBranch.trim();
37
+ if (!want)
38
+ return null;
39
+ for (const s of stacks) {
40
+ if (owner !== undefined && repo !== undefined) {
41
+ if (s.owner !== owner || s.repo !== repo)
42
+ continue;
43
+ }
44
+ for (const layer of s.layers) {
45
+ if (layer.branch === want) {
46
+ return {
47
+ stackId: s.id,
48
+ owner: s.owner,
49
+ repo: s.repo,
50
+ branch: layer.branch,
51
+ prNumber: layer.prNumber,
52
+ position: layer.position,
53
+ };
54
+ }
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Refuse silently growing an already-registered stack.
61
+ * Local unsubmitted parents are fine; only API-listed layers trip this.
62
+ */
63
+ export function assertMayParentOntoRegistered(parentBranch, stacks, extend, verb, owner, repo) {
64
+ const hit = findRegisteredParent(stacks, parentBranch, owner, repo);
65
+ if (!hit)
66
+ return null;
67
+ if (extend)
68
+ return hit;
69
+ const pr = hit.prNumber > 0 ? `PR #${hit.prNumber}` : "an open PR";
70
+ throw new CommandError(`Parent \`${hit.branch}\` is already layer ${hit.position} of registered stack ${hit.stackId} (${hit.owner}/${hit.repo}, ${pr}).\n` +
71
+ `Refusing to ${verb} a new layer onto that stack without an explicit opt-in.\n` +
72
+ `• New independent stack: check out trunk and pass \`--onto <trunk>\` (e.g. \`mg stack create --onto main\`).\n` +
73
+ `• Intentionally grow that stack: pass \`--extend\` on \`stack ${verb}\`.`, 1, "registered_parent");
74
+ }
24
75
  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;
25
76
  export function requireStackId(raw, usage) {
26
77
  const id = raw?.trim() ?? "";
@@ -49,10 +100,35 @@ export function parseAdoptTarget(args) {
49
100
  }
50
101
  throw new CommandError(`usage: mergestorm stack adopt <owner/repo>#<pr>`);
51
102
  }
103
+ const stackCmd = (name, blurb) => ` ${ansi.brightGreen(name.padEnd(26))}${blurb}`;
104
+ /** Empty + populated bodies for the TTY `stack list` panel. */
105
+ export function buildStackListLines(stacks) {
106
+ if (stacks.length === 0) {
107
+ return [
108
+ ` ${ansi.bold("None yet")}`,
109
+ " No registered stacks for this key.",
110
+ "",
111
+ ` ${ansi.bold("Author one")}`,
112
+ stackCmd("stack create", "new layer on this branch"),
113
+ " commit, then stack submit",
114
+ "",
115
+ ` ${ansi.bold("Or import")}`,
116
+ stackCmd("stack adopt org/repo#12", "open GitHub chain"),
117
+ "",
118
+ ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"),
119
+ ];
120
+ }
121
+ const lines = [];
122
+ for (const s of stacks) {
123
+ lines.push(...formatStackHuman(s));
124
+ lines.push("");
125
+ }
126
+ lines.push(ansi.dim(" create · submit · land <id> · q to close"));
127
+ return lines;
128
+ }
52
129
  function formatStackHuman(s) {
53
130
  const lines = [];
54
- const auto = s.autoPromoteWhenGreen ? " · auto-promote" : "";
55
- lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}${auto}`);
131
+ lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}`);
56
132
  lines.push(` ${ansi.dim(s.id)}`);
57
133
  if (s.layers.length === 0) {
58
134
  lines.push(ansi.dim(" (no layers)"));
@@ -70,6 +146,7 @@ export function parseStackCreateArgs(args) {
70
146
  let name;
71
147
  let onto;
72
148
  let trunk;
149
+ let extend = false;
73
150
  let asJson = false;
74
151
  for (let i = 0; i < args.length; i++) {
75
152
  const a = args[i];
@@ -77,10 +154,15 @@ export function parseStackCreateArgs(args) {
77
154
  asJson = true;
78
155
  continue;
79
156
  }
157
+ if (a === "--extend") {
158
+ extend = true;
159
+ continue;
160
+ }
80
161
  if (a === "--onto") {
81
162
  const v = args[++i];
82
- if (!v)
83
- throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>]");
163
+ if (!v) {
164
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend]");
165
+ }
84
166
  onto = v;
85
167
  continue;
86
168
  }
@@ -90,8 +172,9 @@ export function parseStackCreateArgs(args) {
90
172
  }
91
173
  if (a === "--trunk") {
92
174
  const v = args[++i];
93
- if (!v)
94
- throw new CommandError("usage: mergestorm stack create [name] [--trunk <branch>]");
175
+ if (!v) {
176
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend]");
177
+ }
95
178
  trunk = v;
96
179
  continue;
97
180
  }
@@ -103,20 +186,40 @@ export function parseStackCreateArgs(args) {
103
186
  throw new CommandError(`${STACK_USAGE}\nunknown flag: ${a}`);
104
187
  }
105
188
  if (name != null) {
106
- throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>]");
189
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend]");
107
190
  }
108
191
  name = a;
109
192
  }
110
- return { name, onto, trunk, asJson };
193
+ return { name, onto, trunk, extend, asJson };
194
+ }
195
+ /** Parse `stack submit` argv. Exported for tests. */
196
+ export function parseStackSubmitArgs(args) {
197
+ let extend = false;
198
+ let asJson = false;
199
+ for (const a of args) {
200
+ if (a === "--json") {
201
+ asJson = true;
202
+ continue;
203
+ }
204
+ if (a === "--extend") {
205
+ extend = true;
206
+ continue;
207
+ }
208
+ throw new CommandError("usage: mergestorm stack submit [--extend] [--json]");
209
+ }
210
+ return { extend, asJson };
111
211
  }
112
212
  export function parseStackResetArgs(args) {
113
213
  if (args.length !== 1 || args[0] !== "--force") {
114
214
  throw new CommandError("usage: mergestorm stack reset --force\nClears only CLI-managed pre-submit state; branches, PRs, and registered stacks are unchanged.");
115
215
  }
116
216
  }
117
- async function cmdStackCreate(args) {
118
- const { name, onto, trunk: trunkFlag, asJson } = parseStackCreateArgs(args);
119
- const cwd = process.cwd();
217
+ async function cmdStackCreate(args, deps = {}) {
218
+ const { name, onto, trunk: trunkFlag, extend, asJson } = parseStackCreateArgs(args);
219
+ const cwd = deps.cwd ?? process.cwd();
220
+ const listStacksFn = deps.listStacks ?? listStacks;
221
+ const loadConfigFn = deps.loadConfig ?? loadConfig;
222
+ const parseOriginFn = deps.parseGithubOriginRepo ?? parseGithubOriginRepo;
120
223
  try {
121
224
  gitTopLevel(cwd);
122
225
  }
@@ -145,6 +248,37 @@ async function cmdStackCreate(args) {
145
248
  else if (trunkFlag?.trim() && meta.trunk !== trunk) {
146
249
  throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Run \`mg stack reset --force\` to discard local pre-submit state.`);
147
250
  }
251
+ // --trunk only sets metadata; parent defaults to current branch. Call that out
252
+ // when someone passes --trunk while sitting on a different branch without --onto.
253
+ if (trunkFlag?.trim() && !onto?.trim() && parentBranch !== trunk) {
254
+ console.error(ansi.dim(` note: --trunk ${trunk} does not change the parent; new layer parents onto \`${parentBranch}\`. ` +
255
+ `Pass \`--onto ${trunk}\` for a fresh stack from trunk.`));
256
+ }
257
+ // Block silent attach onto an already-submitted stack tip (requires --extend).
258
+ // --trunk is user metadata, so a registered tip must not be able to masquerade
259
+ // as trunk — always consult the stacks API rather than trusting parent === trunk.
260
+ // Best-effort: API/key failures degrade to a warning so create stays local.
261
+ const origin = parseOriginFn(cwd);
262
+ let registeredHit = null;
263
+ if (origin) {
264
+ try {
265
+ const cfg = await loadConfigFn();
266
+ const registered = await listStacksFn(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
267
+ registeredHit = assertMayParentOntoRegistered(parentBranch, registered, extend, "create", origin.owner, origin.repo);
268
+ }
269
+ catch (err) {
270
+ if (err instanceof CommandError && err.code === "registered_parent")
271
+ throw err;
272
+ console.error(ansi.dim(` warning: could not check registered stacks (${err instanceof Error ? err.message : String(err)}); ` +
273
+ `skipping --extend guard. Pass --extend if you intend to grow an open stack.`));
274
+ }
275
+ }
276
+ else {
277
+ console.error(ansi.dim(" warning: could not determine GitHub owner/repo from `origin`; skipping --extend guard. Pass --extend if you intend to grow an open stack."));
278
+ }
279
+ if (parentBranch === trunk && extend && !registeredHit) {
280
+ throw new CommandError("`--extend` is only valid when parenting onto a registered stack layer, not trunk. Omit --extend (or pass --onto <tip> --extend).");
281
+ }
148
282
  try {
149
283
  meta = selectStackForCreate(meta, parentBranch);
150
284
  }
@@ -174,6 +308,7 @@ async function cmdStackCreate(args) {
174
308
  branch,
175
309
  parentBranch,
176
310
  trunk: meta.trunk,
311
+ extend: Boolean(registeredHit),
177
312
  active: meta.active,
178
313
  stacks: meta.stacks.length,
179
314
  layers,
@@ -181,15 +316,19 @@ async function cmdStackCreate(args) {
181
316
  }, null, 2));
182
317
  return;
183
318
  }
184
- console.log(ansi.green(` Created ${branch} (onto ${parentBranch})`));
185
- console.log(ansi.dim(` trunk: ${meta.trunk} · local stacks: ${meta.stacks.length} · active layers: ${layers.length}`));
186
- console.log(ansi.dim(" authoring state: managed automatically outside the repository"));
319
+ const created = [
320
+ ansi.brightGreen(` Created ${branch} (onto ${parentBranch})`),
321
+ ];
322
+ if (registeredHit) {
323
+ created.push(ansi.dim(` extending registered stack ${registeredHit.stackId}` +
324
+ (registeredHit.prNumber > 0 ? ` (from #${registeredHit.prNumber})` : "")));
325
+ }
326
+ created.push(ansi.dim(` trunk: ${meta.trunk} · local stacks: ${meta.stacks.length} · active layers: ${layers.length}`));
327
+ created.push(ansi.dim(" authoring state: managed automatically outside the repository"));
328
+ await present("Stack", created);
187
329
  }
188
330
  export async function cmdStackSubmit(args, deps = {}) {
189
- const asJson = args.includes("--json");
190
- if (args.some((a) => a !== "--json")) {
191
- throw new CommandError("usage: mergestorm stack submit [--json]");
192
- }
331
+ const { extend, asJson } = parseStackSubmitArgs(args);
193
332
  const cwd = deps.cwd ?? process.cwd();
194
333
  const gitTopLevelFn = deps.gitTopLevel ?? gitTopLevel;
195
334
  const loadStackMetaFn = deps.loadStackMeta ?? loadStackMeta;
@@ -197,6 +336,7 @@ export async function cmdStackSubmit(args, deps = {}) {
197
336
  const parseOriginFn = deps.parseGithubOriginRepo ?? parseGithubOriginRepo;
198
337
  const requireGhFn = deps.requireGh ?? requireGh;
199
338
  const branchExistsFn = deps.branchExists ?? branchExists;
339
+ const fetchRemoteBranchFn = deps.fetchRemoteBranch ?? fetchRemoteBranch;
200
340
  const pushBranchFn = deps.pushBranch ?? pushBranch;
201
341
  const findOpenPrNumberFn = deps.findOpenPrNumber ?? findOpenPrNumber;
202
342
  const commitsAheadOfFn = deps.commitsAheadOf ?? commitsAheadOf;
@@ -205,7 +345,9 @@ export async function cmdStackSubmit(args, deps = {}) {
205
345
  const buildPrBodyFn = deps.buildPrBodyFromCommit ?? buildPrBodyFromCommit;
206
346
  const createPrFn = deps.createPr ?? createPr;
207
347
  const loadConfigFn = deps.loadConfig ?? loadConfig;
348
+ const listStacksFn = deps.listStacks ?? listStacks;
208
349
  const adoptStackFn = deps.adoptStack ?? adoptStack;
350
+ const ensureUpperParkFn = deps.ensureUpperPark ?? ensureUpperPark;
209
351
  try {
210
352
  gitTopLevelFn(cwd);
211
353
  }
@@ -223,9 +365,41 @@ export async function cmdStackSubmit(args, deps = {}) {
223
365
  }
224
366
  const { owner, repo } = origin;
225
367
  requireGhFn(cwd);
226
- const opened = [];
368
+ // Safety net: refuse submit that would open/register onto a registered tip
369
+ // without --extend. Check every layer's parent (mid-stack layers from older
370
+ // local state must not sail through), scoped to this repo, and fail closed
371
+ // when the API cannot be consulted — otherwise the attach happens anyway.
372
+ // meta.trunk is user metadata, so it must not exempt a registered tip from
373
+ // the check (a tip can masquerade as trunk via --trunk at create).
374
+ const parents = [...new Set(layers.map((l) => l.parentBranch || meta.trunk))];
375
+ let registeredHits = 0;
376
+ let registered = [];
377
+ try {
378
+ const cfg = await loadConfigFn();
379
+ registered = await listStacksFn(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
380
+ for (const parent of parents) {
381
+ if (assertMayParentOntoRegistered(parent, registered, extend, "submit", owner, repo)) {
382
+ registeredHits += 1;
383
+ }
384
+ }
385
+ }
386
+ catch (err) {
387
+ if (err instanceof CommandError && err.code === "registered_parent")
388
+ throw err;
389
+ throw new CommandError(`Could not verify that the stack parents onto registered stacks (${err instanceof Error ? err.message : String(err)}); ` +
390
+ `refusing to submit. If a layer grows a registered stack, pass --extend (retry when the API is reachable).`);
391
+ }
392
+ if (extend && registeredHits === 0 && parents.every((p) => p === meta.trunk)) {
393
+ throw new CommandError("`--extend` is only valid when a layer parents onto a registered stack layer, not trunk.");
394
+ }
395
+ const plans = planSubmitLayerBases({
396
+ layers,
397
+ trunk: meta.trunk,
398
+ registered,
399
+ owner,
400
+ repo,
401
+ });
227
402
  for (const layer of layers) {
228
- const base = layer.parentBranch || meta.trunk;
229
403
  if (!branchExistsFn(layer.branch, cwd)) {
230
404
  throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
231
405
  }
@@ -236,58 +410,109 @@ export async function cmdStackSubmit(args, deps = {}) {
236
410
  catch (err) {
237
411
  throw new CommandError(err instanceof Error ? err.message : String(err));
238
412
  }
239
- let prNumber = findOpenPrNumberFn(owner, repo, layer.branch, cwd);
240
- let created = false;
241
- if (prNumber == null) {
242
- // Refuse empty layers before `gh pr create` dumps opaque stderr.
243
- if (!branchExistsFn(base, cwd)) {
244
- throw new CommandError(`Stack base branch missing locally: ${base}`);
245
- }
246
- let ahead;
413
+ }
414
+ const opened = [];
415
+ const pendingPark = plans.filter((plan) => {
416
+ if (findOpenPrNumberFn(owner, repo, plan.branch, cwd) != null)
417
+ return false;
418
+ return plan.githubBase == null;
419
+ });
420
+ const openLayerPr = (branch, base) => {
421
+ const existing = findOpenPrNumberFn(owner, repo, branch, cwd);
422
+ if (existing != null) {
423
+ console.log(ansi.dim(` PR #${existing} already open for ${branch}`));
424
+ return { prNumber: existing, created: false };
425
+ }
426
+ const aheadRef = isMgParkBranch(base) ? `origin/${base}` : base;
427
+ if (isMgParkBranch(base)) {
247
428
  try {
248
- ahead = commitsAheadOfFn(base, layer.branch, cwd);
429
+ fetchRemoteBranchFn(base, cwd);
249
430
  }
250
431
  catch (err) {
251
- throw new CommandError(err instanceof Error ? err.message : String(err));
252
- }
253
- if (ahead < 1) {
254
- throw new CommandError(`No commits on \`${layer.branch}\` ahead of \`${base}\`. ` +
255
- `Commit your changes, then retry \`mg stack submit\`.`);
432
+ throw new CommandError(`Could not fetch park base \`${base}\` (${err instanceof Error ? err.message : String(err)}).`);
256
433
  }
257
- const title = tipCommitSubjectFn(layer.branch, cwd);
258
- const body = buildPrBodyFn(tipCommitMessageFn(layer.branch, cwd));
259
- console.log(ansi.dim(` Opening PR ${layer.branch} → ${base} …`));
260
- prNumber = createPrFn({
434
+ }
435
+ else if (!branchExistsFn(base, cwd)) {
436
+ throw new CommandError(`Stack base branch missing locally: ${base}`);
437
+ }
438
+ let ahead;
439
+ try {
440
+ ahead = commitsAheadOfFn(aheadRef, branch, cwd);
441
+ }
442
+ catch (err) {
443
+ throw new CommandError(err instanceof Error ? err.message : String(err));
444
+ }
445
+ if (ahead < 1) {
446
+ throw new CommandError(`No commits on \`${branch}\` ahead of \`${base}\`. ` +
447
+ `Commit your changes, then retry \`mg stack submit\`.`);
448
+ }
449
+ const title = tipCommitSubjectFn(branch, cwd);
450
+ const body = buildPrBodyFn(tipCommitMessageFn(branch, cwd));
451
+ console.log(ansi.dim(` Opening PR ${branch} → ${base} …`));
452
+ return {
453
+ prNumber: createPrFn({
261
454
  owner,
262
455
  repo,
263
456
  base,
264
- head: layer.branch,
457
+ head: branch,
265
458
  title,
266
459
  body,
267
460
  cwd,
268
- });
269
- created = true;
270
- }
271
- else {
272
- console.log(ansi.dim(` PR #${prNumber} already open for ${layer.branch}`));
461
+ }),
462
+ created: true,
463
+ };
464
+ };
465
+ for (const plan of plans) {
466
+ if (plan.githubBase == null && findOpenPrNumberFn(owner, repo, plan.branch, cwd) == null) {
467
+ continue;
273
468
  }
274
- opened.push({ branch: layer.branch, base, prNumber, created });
469
+ const base = plan.githubBase ?? plan.gitParent;
470
+ const { prNumber, created } = openLayerPr(plan.branch, base);
471
+ opened.push({ branch: plan.branch, base, prNumber, created });
275
472
  }
276
- const registerPr = opened[0].prNumber;
277
473
  const cfg = await loadConfigFn();
278
- console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${registerPr}) …`));
279
- const body = await adoptStackFn(owner, repo, registerPr, cfg);
280
- if (typeof body !== "object" || body === null) {
281
- throw new CommandError("Unexpected response shape from adopt API");
282
- }
283
- const data = body;
284
- if (data.error) {
285
- throw new CommandError(data.error);
474
+ let adoptData = null;
475
+ let stackId = plans.find((plan) => plan.stackId)?.stackId ?? null;
476
+ const registerPr = async (prNumber) => {
477
+ console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${prNumber}) …`));
478
+ const body = await adoptStackFn(owner, repo, prNumber, cfg);
479
+ if (typeof body !== "object" || body === null) {
480
+ throw new CommandError("Unexpected response shape from adopt API");
481
+ }
482
+ const data = body;
483
+ if (data.error) {
484
+ throw new CommandError(data.error);
485
+ }
486
+ if (!data.stack) {
487
+ throw new CommandError("Stack was not registered");
488
+ }
489
+ adoptData = data;
490
+ if (typeof data.stack.id === "string" && data.stack.id) {
491
+ stackId = data.stack.id;
492
+ }
493
+ };
494
+ if (pendingPark.length > 0) {
495
+ if (opened.length > 0) {
496
+ await registerPr(opened[0].prNumber);
497
+ }
498
+ if (!stackId) {
499
+ throw new CommandError("Cannot mint a park freeze before the stack is registered. Open layers 1–2 first, then retry.");
500
+ }
501
+ console.log(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
502
+ const park = await ensureUpperParkFn(stackId, cfg);
503
+ for (const plan of pendingPark) {
504
+ const { prNumber, created } = openLayerPr(plan.branch, park.freezeBranch);
505
+ opened.push({ branch: plan.branch, base: park.freezeBranch, prNumber, created });
506
+ }
507
+ await registerPr(opened[opened.length - 1].prNumber);
286
508
  }
287
- if (!data.stack) {
288
- throw new CommandError("Stack was not registered");
509
+ else {
510
+ if (opened.length === 0) {
511
+ throw new CommandError("No pull requests to register after submit.");
512
+ }
513
+ await registerPr(opened[0].prNumber);
289
514
  }
290
- const stackId = data.stack.id;
515
+ const data = adoptData;
291
516
  // Drop the submitted local stack so the next create onto trunk starts fresh.
292
517
  await saveStackMetaFn(removeActiveStack(meta), cwd);
293
518
  if (asJson) {
@@ -301,22 +526,26 @@ export async function cmdStackSubmit(args, deps = {}) {
301
526
  }, null, 2));
302
527
  return;
303
528
  }
529
+ const summary = [];
304
530
  for (const row of opened) {
305
531
  const verb = row.created ? "opened" : "exists";
306
- console.log(ansi.green(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
532
+ summary.push(ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
307
533
  }
308
534
  if (stackId) {
309
- console.log(ansi.green(` Registered stack ${stackId}`));
535
+ summary.push(ansi.brightGreen(` Registered stack ${stackId}`));
310
536
  }
311
537
  else {
312
- console.log(ansi.dim(" Stack registration returned no stack ID"));
538
+ summary.push(ansi.dim(" Stack registration returned no stack ID"));
313
539
  }
314
- console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
540
+ summary.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
541
+ await present("Stack", summary);
315
542
  }
316
543
  async function cmdStackReset(args) {
317
544
  parseStackResetArgs(args);
318
545
  await resetStackMeta(process.cwd());
319
- console.log(ansi.green(" Cleared CLI-managed pre-submit stack state. Branches, PRs, and registered stacks were not changed."));
546
+ await present("Stack", [
547
+ ansi.brightGreen(" Cleared CLI-managed pre-submit stack state. Branches, PRs, and registered stacks were not changed."),
548
+ ]);
320
549
  }
321
550
  async function cmdStackList(args) {
322
551
  const asJson = args.includes("--json");
@@ -326,18 +555,26 @@ async function cmdStackList(args) {
326
555
  console.log(JSON.stringify({ stacks }, null, 2));
327
556
  return;
328
557
  }
558
+ if (canBrowse()) {
559
+ await runLineTabsBrowser({
560
+ tabs: [{ id: "yours", label: "Stacks", lines: buildStackListLines(stacks) }],
561
+ });
562
+ return;
563
+ }
329
564
  if (stacks.length === 0) {
330
- console.log(ansi.dim(" No stacks yet. Author with `stack create` → commit → `stack submit`."));
331
- console.log(ansi.dim(" Import an existing chain only: `stack adopt owner/repo#pr`"));
332
- console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
565
+ await present("Stacks", [
566
+ ansi.dim(" No stacks yet. Author with `stack create` → commit → `stack submit`."),
567
+ ansi.dim(" Import an existing chain only: `stack adopt owner/repo#pr`"),
568
+ ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"),
569
+ ]);
333
570
  return;
334
571
  }
572
+ const listed = [];
335
573
  for (const s of stacks) {
336
- for (const line of formatStackHuman(s))
337
- console.log(line);
338
- console.log("");
574
+ listed.push(...formatStackHuman(s), "");
339
575
  }
340
- console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
576
+ listed.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
577
+ await present("Stacks", listed);
341
578
  }
342
579
  async function cmdStackAdopt(args) {
343
580
  const asJson = args.includes("--json");
@@ -358,13 +595,16 @@ async function cmdStackAdopt(args) {
358
595
  const data = body;
359
596
  const stack = data.stack;
360
597
  const chain = data.chain;
361
- console.log(ansi.green(` Imported ${owner}/${repo}#${prNumber}` +
362
- (stack?.id ? ` → ${stack.id}` : "") +
363
- (Array.isArray(chain) ? ` (${chain.length} layer(s))` : "")));
598
+ const imported = [
599
+ ansi.brightGreen(` Imported ${owner}/${repo}#${prNumber}` +
600
+ (stack?.id ? ` → ${stack.id}` : "") +
601
+ (Array.isArray(chain) ? ` (${chain.length} layer(s))` : "")),
602
+ ];
364
603
  if (stack?.trunkBranch) {
365
- console.log(ansi.dim(` trunk: ${stack.trunkBranch}`));
604
+ imported.push(ansi.dim(` trunk: ${stack.trunkBranch}`));
366
605
  }
367
- console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
606
+ imported.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
607
+ await present("Stack", imported);
368
608
  }
369
609
  async function cmdStackRestack(args) {
370
610
  const asJson = args.includes("--json");
@@ -376,7 +616,9 @@ async function cmdStackRestack(args) {
376
616
  return;
377
617
  }
378
618
  const fromBranch = body.fromBranch;
379
- console.log(ansi.green(` Restack started` + (fromBranch ? ` from ${fromBranch}` : "") + ` (${id})`));
619
+ await present("Stack", [
620
+ ansi.brightGreen(` Restack started` + (fromBranch ? ` from ${fromBranch}` : "") + ` (${id})`),
621
+ ]);
380
622
  }
381
623
  async function cmdStackLand(args) {
382
624
  const asJson = args.includes("--json");
@@ -392,39 +634,31 @@ async function cmdStackLand(args) {
392
634
  const branch = body.mergedBranch;
393
635
  const uNumber = body.uNumber;
394
636
  if (typeof uNumber === "number" && uNumber > 0) {
395
- console.log(ansi.green(` Promoted` +
396
- (pr != null ? ` #${pr}` : "") +
397
- ` into U${uNumber}` +
398
- (branch ? ` (${branch})` : "") +
399
- ` on ${id}`));
637
+ await present("Stack", [
638
+ ansi.brightGreen(` Promoted` +
639
+ (pr != null ? ` #${pr}` : "") +
640
+ ` into U${uNumber}` +
641
+ (branch ? ` (${branch})` : "") +
642
+ ` on ${id}`),
643
+ ]);
400
644
  return;
401
645
  }
402
- console.log(ansi.green(` Landed` +
403
- (pr != null ? ` #${pr}` : "") +
404
- (branch ? ` (${branch})` : "") +
405
- ` on ${id}`));
406
- }
407
- async function cmdStackAutoLand(args) {
408
- const asJson = args.includes("--json");
409
- const filtered = args.filter((a) => a !== "--json");
410
- const mode = filtered[0]?.toLowerCase();
411
- const id = requireStackId(filtered[1], "usage: mergestorm stack auto-promote on|off <stack-id>");
412
- if (mode !== "on" && mode !== "off") {
413
- throw new CommandError("usage: mergestorm stack auto-promote on|off <stack-id>");
414
- }
415
- const cfg = await loadConfig();
416
- const body = await setStackAutoPromote(id, mode === "on", cfg);
417
- if (asJson) {
418
- console.log(JSON.stringify(body, null, 2));
419
- return;
420
- }
421
- console.log(ansi.green(` Auto-promote ${mode} for ${id}`));
646
+ await present("Stack", [
647
+ ansi.brightGreen(` Landed` +
648
+ (pr != null ? ` #${pr}` : "") +
649
+ (branch ? ` (${branch})` : "") +
650
+ ` on ${id}`),
651
+ ]);
422
652
  }
423
653
  export async function cmdStack(args) {
424
654
  const sub = args[0]?.toLowerCase();
425
655
  const rest = args.slice(1);
426
656
  // Help must exit 0 — print usage, do not throw CommandError.
427
657
  if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
658
+ if (canBrowse()) {
659
+ await openHelpBrowser("stacks");
660
+ return;
661
+ }
428
662
  console.log(STACK_USAGE);
429
663
  return;
430
664
  }
@@ -442,11 +676,5 @@ export async function cmdStack(args) {
442
676
  return cmdStackRestack(rest);
443
677
  if (sub === "land" || sub === "land-next")
444
678
  return cmdStackLand(rest);
445
- if (sub === "auto-promote" ||
446
- sub === "autopromote" ||
447
- sub === "auto-land" ||
448
- sub === "autoland") {
449
- return cmdStackAutoLand(rest);
450
- }
451
679
  throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
452
680
  }