mergestorm 0.3.64 → 0.3.67

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 +28 -9
  2. package/dist/api.d.ts +77 -11
  3. package/dist/api.js +228 -6
  4. package/dist/automation-catalog.d.ts +9 -1
  5. package/dist/automation-catalog.js +2 -0
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +3 -2
  8. package/dist/client/index.js +3 -2
  9. package/dist/commands/account.d.ts +3 -0
  10. package/dist/commands/account.js +75 -0
  11. package/dist/commands/browse.d.ts +1 -9
  12. package/dist/commands/browse.js +10 -8
  13. package/dist/commands/dismiss.d.ts +0 -2
  14. package/dist/commands/dismiss.js +2 -2
  15. package/dist/commands/help.js +3 -2
  16. package/dist/commands/issue.d.ts +24 -6
  17. package/dist/commands/issue.js +157 -31
  18. package/dist/commands/login.js +46 -14
  19. package/dist/commands/logout.js +6 -7
  20. package/dist/commands/memory.d.ts +2 -3
  21. package/dist/commands/memory.js +8 -6
  22. package/dist/commands/registry.d.ts +1 -0
  23. package/dist/commands/registry.js +35 -11
  24. package/dist/commands/review-client.d.ts +0 -3
  25. package/dist/commands/review-client.js +3 -3
  26. package/dist/commands/review.d.ts +1 -1
  27. package/dist/commands/review.js +1 -1
  28. package/dist/commands/settings.d.ts +3 -5
  29. package/dist/commands/settings.js +5 -3
  30. package/dist/commands/stack.d.ts +19 -5
  31. package/dist/commands/stack.js +88 -4
  32. package/dist/commands/whoami.js +14 -10
  33. package/dist/config.d.ts +55 -2
  34. package/dist/config.js +264 -13
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/gh.d.ts +0 -4
  37. package/dist/gh.js +4 -4
  38. package/dist/git.d.ts +1 -1
  39. package/dist/git.js +2 -2
  40. package/dist/memory-api.d.ts +1 -0
  41. package/dist/memory-api.js +2 -2
  42. package/dist/node-version.d.ts +0 -2
  43. package/dist/node-version.js +1 -1
  44. package/dist/pr-body.d.ts +4 -0
  45. package/dist/pr-body.js +15 -0
  46. package/dist/repo-identity.d.ts +2 -1
  47. package/dist/repo-identity.js +8 -4
  48. package/dist/server-publish.d.ts +1 -24
  49. package/dist/server-publish.js +9 -9
  50. package/dist/stack-blockers.d.ts +12 -38
  51. package/dist/stack-blockers.js +57 -33
  52. package/dist/stack-dto.d.ts +3 -0
  53. package/dist/stack-dto.js +15 -2
  54. package/dist/stack-meta.d.ts +2 -2
  55. package/dist/stack-meta.js +1 -1
  56. package/dist/stack-watch-loop.d.ts +2 -13
  57. package/dist/stack-watch-loop.js +12 -12
  58. package/dist/submit-pr-base.d.ts +0 -1
  59. package/dist/submit-pr-base.js +1 -1
  60. package/dist/ui/ansi.d.ts +0 -10
  61. package/dist/ui/ansi.js +2 -2
  62. package/dist/ui/banner.d.ts +3 -1
  63. package/dist/ui/banner.js +13 -3
  64. package/dist/ui/box.d.ts +0 -2
  65. package/dist/ui/box.js +1 -1
  66. package/dist/ui/prompt.d.ts +0 -11
  67. package/dist/ui/prompt.js +7 -7
  68. package/dist/ui/tabs.d.ts +2 -2
  69. package/dist/ui/tabs.js +1 -1
  70. package/dist/ui/width.d.ts +0 -9
  71. package/dist/ui/width.js +3 -3
  72. package/dist/web-api.d.ts +17 -0
  73. package/dist/web-api.js +30 -0
  74. package/package.json +1 -1
  75. package/skill/SKILL.md +4 -0
  76. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import { platform } from "node:os";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { apiBase, configPath, loadConfig, mergestormHome, saveConfig, } from "../config.js";
6
- import { devicePost, getMe } from "../api.js";
5
+ import { accountNameFromMe, apiBase, configFor, configPath, mergestormHome, provisionalAccountName, readStore, saveAccount, } from "../config.js";
6
+ import { accountLabel, devicePost, getMe, revokeOwnKey } from "../api.js";
7
7
  import { CommandError } from "../errors.js";
8
8
  import { ansi } from "../ui/ansi.js";
9
9
  import { present } from "../ui/present.js";
@@ -40,6 +40,29 @@ export async function validateApiKey(cfg) {
40
40
  throw err;
41
41
  }
42
42
  }
43
+ async function loginConfig() {
44
+ const store = await readStore();
45
+ return configFor(store, store.activeAccount);
46
+ }
47
+ async function storeLogin(cfg, me, opts) {
48
+ const apiKey = cfg.apiKey ?? "";
49
+ const name = accountNameFromMe(me) ?? provisionalAccountName(apiKey);
50
+ const before = await readStore();
51
+ const replaced = Object.hasOwn(before.accounts, name) ? configFor(before, name) : undefined;
52
+ try {
53
+ await saveAccount(name, { apiKey, apiBase: cfg.apiBase }, { activate: true });
54
+ }
55
+ catch (err) {
56
+ if (opts.minted && err instanceof CommandError && err.code === "account_limit") {
57
+ await revokeOwnKey(cfg, { timeoutMs: 8_000 });
58
+ }
59
+ throw err;
60
+ }
61
+ if (replaced?.apiKey && replaced.apiKey !== apiKey) {
62
+ await revokeOwnKey({ ...replaced, ignoreEnv: true }, { timeoutMs: 8_000 });
63
+ }
64
+ return name;
65
+ }
43
66
  async function loginWithKey() {
44
67
  // Always use muted secret input on TTY so the key never lands in
45
68
  // question()/history echo.
@@ -47,12 +70,12 @@ async function loginWithKey() {
47
70
  if (!key.startsWith("msk_live_")) {
48
71
  throw new CommandError("Key should start with msk_live_");
49
72
  }
50
- const cfg = await loadConfig();
51
- cfg.apiKey = key;
52
- const me = await validateApiKey(cfg);
53
- await saveConfig(cfg);
73
+ const cfg = await loginConfig();
74
+ const candidate = { apiKey: key, apiBase: apiBase(cfg), ignoreEnv: true };
75
+ const me = await validateApiKey(candidate);
76
+ const name = await storeLogin(candidate, me, { minted: false });
54
77
  const prefix = me.key?.prefix?.trim() || "msk_live_…";
55
- await present("Login", [` Verified ${prefix}… · saved to ${configPath()}`]);
78
+ await present("Login", [` Logged in as ${name}. Verified ${prefix}… · saved to ${configPath()}`]);
56
79
  }
57
80
  export function pendingLoginPath() {
58
81
  return path.join(mergestormHome(), "login-pending.json");
@@ -140,12 +163,21 @@ async function pollDeviceLogin(pending, retryCommand, recovery) {
140
163
  throw new CommandError(`Login failed: network error. Check your connection and run \`${retryCommand}\` again.`);
141
164
  }
142
165
  if (poll.status === 200 && poll.body?.api_key) {
143
- const next = await loadConfig();
144
- next.apiKey = poll.body.api_key;
145
- next.apiBase = pending.apiBase;
146
- await saveConfig(next);
166
+ const minted = { apiKey: poll.body.api_key, apiBase: pending.apiBase, ignoreEnv: true };
147
167
  await clearPendingLogin(pending.deviceCode);
148
- await present("Login", [` Logged in. Key saved to ${configPath()}`]);
168
+ let me = accountLabel(poll.body?.account)
169
+ ? { account: poll.body.account }
170
+ : null;
171
+ if (!me) {
172
+ try {
173
+ me = await getMe(minted, { timeoutMs: 8_000 });
174
+ }
175
+ catch {
176
+ me = null;
177
+ }
178
+ }
179
+ const name = await storeLogin(minted, me, { minted: true });
180
+ await present("Login", [` Logged in as ${name}. Key saved to ${configPath()}`]);
149
181
  return;
150
182
  }
151
183
  const err = poll.body?.error;
@@ -177,7 +209,7 @@ async function pollDeviceLogin(pending, retryCommand, recovery) {
177
209
  throw new CommandError(`Login timed out. ${recovery}`);
178
210
  }
179
211
  async function loginStart() {
180
- const cfg = await loadConfig();
212
+ const cfg = await loginConfig();
181
213
  const base = apiBase(cfg);
182
214
  const pending = pendingFromStart(base, await startDeviceLogin(base));
183
215
  await savePendingLogin(pending);
@@ -213,7 +245,7 @@ export async function cmdLogin(args, deps = { openBrowser }) {
213
245
  await loginFinish();
214
246
  return;
215
247
  }
216
- const cfg = await loadConfig();
248
+ const cfg = await loginConfig();
217
249
  const base = apiBase(cfg);
218
250
  const pending = pendingFromStart(base, await startDeviceLogin(base));
219
251
  console.log("To sign in, open this URL and confirm the code:\n");
@@ -1,12 +1,11 @@
1
- import { clearConfig } from "../config.js";
1
+ import { loadConfig } from "../config.js";
2
2
  import { CommandError } from "../errors.js";
3
3
  import { present } from "../ui/present.js";
4
+ import { logoutAccount } from "./account.js";
4
5
  export async function cmdLogout() {
5
- try {
6
- await clearConfig();
7
- await present("Logout", [" Logged out. Removed stored API key."]);
8
- }
9
- catch (err) {
10
- throw new CommandError(`Could not remove config: ${err instanceof Error ? err.message : String(err)}`);
6
+ const cfg = await loadConfig();
7
+ if (!cfg.account) {
8
+ throw new CommandError("Not logged in. Nothing to log out of.");
11
9
  }
10
+ await present("Logout", await logoutAccount(cfg.account));
12
11
  }
@@ -1,16 +1,15 @@
1
1
  import { type Config } from "../config.js";
2
2
  import { deleteMemory, getMemoryMode, listMemories, saveMemory, searchMemories, setMemoryMode, updateMemory, type MemoryItem, type MemoryKind, type MemoryScope } from "../memory-api.js";
3
- export declare const MEMORY_USAGE: string;
4
- export declare const MEMORY_SUBCOMMANDS: readonly ["list", "search", "add", "update", "delete", "mode"];
5
3
  type Flags = {
6
4
  positional: string[];
7
5
  json: boolean;
6
+ keepBoth?: boolean;
8
7
  repo?: string;
9
8
  kind?: MemoryKind;
10
9
  scope?: MemoryScope;
11
10
  limit?: number;
12
11
  };
13
- export declare function parseMemoryFlags(args: string[], allowed: ReadonlyArray<"repo" | "kind" | "scope" | "limit">): Flags;
12
+ export declare function parseMemoryFlags(args: string[], allowed: ReadonlyArray<"repo" | "kind" | "scope" | "limit" | "keep-both">): Flags;
14
13
  export declare function formatMemoryLine(memory: MemoryItem): string;
15
14
  export type MemoryDeps = {
16
15
  loadConfig?: () => Promise<Config>;
@@ -1,18 +1,18 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { CommandError, REVIEW_EXIT } from "../errors.js";
3
3
  import { MEMORY_KINDS, MEMORY_SEARCH_LIMIT_MAX, deleteMemory, getMemoryMode, isMemoryKind, isMemoryMode, isMemoryScope, listMemories, memoryChatId, saveMemory, searchMemories, setMemoryMode, updateMemory, } from "../memory-api.js";
4
- export const MEMORY_USAGE = [
4
+ const MEMORY_USAGE = [
5
5
  "usage: mergestorm memory list [--scope user|repo] [--repo <owner/repo>] [--kind <kind>] [--json]",
6
6
  " mergestorm memory search <query> [--repo <owner/repo>] [--limit <1-20>] [--json]",
7
- " mergestorm memory add <text> --kind <kind> [--repo <owner/repo>] [--json]",
7
+ " mergestorm memory add <text> --kind <kind> [--repo <owner/repo>] [--keep-both] [--json]",
8
8
  " mergestorm memory update <id> <text> [--kind <kind>] [--json]",
9
9
  " mergestorm memory delete <id> [--json]",
10
10
  " mergestorm memory mode [off|on_request|all] [--json]",
11
11
  ` kind is one of ${MEMORY_KINDS.join(", ")}. With --repo a memory is about that repository, otherwise about you.`,
12
+ " add refuses text close to a memory you have; update that memory, or add again with --keep-both when they are different things.",
12
13
  " search covers your memories, and with --repo that repository's memories and standing rules too.",
13
14
  " Inside a Breeze chat (MERGESTORM_CHAT_ID), add and search follow that chat's memory setting.",
14
15
  ].join("\n");
15
- export const MEMORY_SUBCOMMANDS = ["list", "search", "add", "update", "delete", "mode"];
16
16
  const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
17
17
  function usage(message) {
18
18
  return new CommandError(message ? `${message}\n${MEMORY_USAGE}` : MEMORY_USAGE, REVIEW_EXIT.usage, "usage");
@@ -29,6 +29,8 @@ export function parseMemoryFlags(args, allowed) {
29
29
  const name = arg.startsWith("--") ? arg.slice(2) : null;
30
30
  if (arg === "--json")
31
31
  flags.json = true;
32
+ else if (name === "keep-both" && allowed.includes("keep-both"))
33
+ flags.keepBoth = true;
32
34
  else if (name === "repo" && allowed.includes("repo")) {
33
35
  const repo = value(arg, args[++i]).replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/i, "");
34
36
  if (!REPO.test(repo))
@@ -108,14 +110,14 @@ export async function cmdMemory(args, deps = {}) {
108
110
  return;
109
111
  }
110
112
  if (sub === "add" || sub === "save" || sub === "remember") {
111
- const flags = parseMemoryFlags(rest, ["repo", "kind"]);
113
+ const flags = parseMemoryFlags(rest, ["repo", "kind", "keep-both"]);
112
114
  const text = flags.positional.join(" ").trim();
113
115
  if (!text)
114
116
  throw usage("add needs the text to remember");
115
117
  if (!flags.kind)
116
118
  throw usage(`add needs --kind (${MEMORY_KINDS.join(", ")})`);
117
- const saved = await api.saveMemory({ text, kind: flags.kind, ...(flags.repo ? { repo: flags.repo } : {}), chatId }, await config());
118
- emit(flags.json, saved, () => [saved.merged ? `That matched memory ${saved.id}, which now says this.` : `Saved to memory as ${saved.id}.`]);
119
+ const saved = await api.saveMemory({ text, kind: flags.kind, ...(flags.repo ? { repo: flags.repo } : {}), chatId, ...(flags.keepBoth ? { keepBoth: true } : {}) }, await config());
120
+ emit(flags.json, saved, () => [saved.merged ? `Memory ${saved.id} already says this.` : `Saved to memory as ${saved.id}.`]);
119
121
  return;
120
122
  }
121
123
  if (sub === "update" || sub === "edit") {
@@ -3,6 +3,7 @@ import type { CommandSpec } from "../ui/prompt.js";
3
3
  export type CommandContext = {
4
4
  mode: "oneshot" | "shell";
5
5
  signal?: AbortSignal;
6
+ account?: string;
6
7
  /** Shell: refresh banner after login/logout. */
7
8
  afterAuth?: () => Promise<void>;
8
9
  };
@@ -1,5 +1,6 @@
1
- import { DEFAULT_API } from "../config.js";
1
+ import { DEFAULT_API, splitAccountFlag, withAccount } from "../config.js";
2
2
  import { CommandError } from "../errors.js";
3
+ import { cmdAccount } from "./account.js";
3
4
  import { cmdBranches, cmdChain } from "./branches.js";
4
5
  import { canBrowse, openTabsBrowser } from "./browse.js";
5
6
  import { cmdCredits } from "./credits.js";
@@ -24,9 +25,9 @@ import { cmdWhoami } from "./whoami.js";
24
25
  export const COMMAND_REGISTRY = [
25
26
  {
26
27
  name: "login",
27
- summary: "Sign in via browser (or paste a key)",
28
+ summary: "Sign in via browser (or paste a key); adds an account, up to 3",
28
29
  usage: [
29
- "mergestorm login Sign in via browser and store an API key",
30
+ "mergestorm login Sign in via browser and add the account (up to 3 saved)",
30
31
  "mergestorm login --start Print a sign-in URL and code, then exit (agents)",
31
32
  "mergestorm login --finish Store the API key once the code from --start is approved",
32
33
  "mergestorm login --key Paste an existing API key instead (headless/CI)",
@@ -39,14 +40,28 @@ export const COMMAND_REGISTRY = [
39
40
  },
40
41
  {
41
42
  name: "logout",
42
- summary: "Remove the stored API key",
43
- usage: ["mergestorm logout Remove the stored API key"],
43
+ summary: "Log out of the active account and revoke its key",
44
+ usage: ["mergestorm logout Log out of the active account (or --account <name>) and revoke its key"],
44
45
  async run(_args, ctx) {
45
46
  await cmdLogout();
46
47
  if (ctx.afterAuth)
47
48
  await ctx.afterAuth();
48
49
  },
49
50
  },
51
+ {
52
+ name: "account",
53
+ summary: "Saved accounts: list, use <name>, remove <name>",
54
+ usage: [
55
+ "mergestorm account list [--json] Saved accounts (up to 3), the active one marked",
56
+ "mergestorm account use <name> Make <name> the active account",
57
+ "mergestorm account remove <name> Log out of <name> and revoke its key",
58
+ ],
59
+ async run(args, ctx) {
60
+ await cmdAccount(args);
61
+ if (ctx.afterAuth)
62
+ await ctx.afterAuth();
63
+ },
64
+ },
50
65
  {
51
66
  name: "review",
52
67
  summary: "Review git diff (default origin/HEAD or main or master)",
@@ -102,11 +117,15 @@ export const COMMAND_REGISTRY = [
102
117
  },
103
118
  {
104
119
  name: "issue",
105
- summary: "Open a GitHub issue through Mergestorm, no gh needed",
120
+ summary: "Open a GitHub issue through Mergestorm and wait for its Vortex review",
106
121
  usage: [
107
- "mergestorm issue create --title <title> [--body <text> | --body-file <path>] [--label <name>]… [--repo <owner/repo>] [--json]",
122
+ "mergestorm issue create --title <title> [--body <text> | --body-file <path>] [--label <name>]… [--repo <owner/repo>] [--wait [--timeout <s>]] [--json]",
108
123
  " Opens the issue as the Surge App; needs write access on GitHub, uses existing labels only",
109
124
  " The same title again returns the open issue it already made when it is among the newest 100 open issues; an older one, or two calls at once, can still create a duplicate",
125
+ " --wait then waits for the Vortex issue review, like issue wait",
126
+ "mergestorm issue wait <n> [--repo <owner/repo>] [--timeout <s>] [--json]",
127
+ " Waits for the Vortex issue review (default 900s); exits 0 once approved or when issue reviews are off",
128
+ " Exits 4 with the points to answer: reply in a comment on the issue, wait again, and open the PR only after approval",
110
129
  ],
111
130
  async run(args) {
112
131
  await cmdIssue(args);
@@ -186,7 +205,7 @@ export const COMMAND_REGISTRY = [
186
205
  {
187
206
  name: "whoami",
188
207
  summary: "Key + plan (Status tab on a TTY)",
189
- usage: ["mergestorm whoami [--json] Key + plan + API base"],
208
+ usage: ["mergestorm whoami [--json] Account + key + plan + API base"],
190
209
  async run(args, ctx) {
191
210
  await cmdWhoami(args, { mode: ctx.mode });
192
211
  },
@@ -201,6 +220,7 @@ export const COMMAND_REGISTRY = [
201
220
  " [--cyclone-skip-ci on|off]",
202
221
  " [--cyclone-patch-unverified on|off] Patch languages we cannot typecheck",
203
222
  " [--vortex-seam on|off] [--vortex-skip-all-clear on|off]",
223
+ " [--issue-review on|off] Account default for Vortex issue reviews; --repo overrides it per repository",
204
224
  " [--auto-land on|off] Auto land default for new stacks",
205
225
  " [--auto-land-settle <seconds>] How long Auto land waits before queueing (15-300, default 60)",
206
226
  " [--merge-queue-batch on|off] Test several queued pull requests on one CI run",
@@ -225,7 +245,7 @@ export const COMMAND_REGISTRY = [
225
245
  usage: [
226
246
  "mergestorm memory list [--scope user|repo] [--repo <owner/repo>] [--kind <kind>] [--json]",
227
247
  "mergestorm memory search <query> [--repo <owner/repo>] [--limit <1-20>] [--json]",
228
- "mergestorm memory add <text> --kind rule|style|preference|fact|project [--repo <owner/repo>] [--json]",
248
+ "mergestorm memory add <text> --kind rule|style|preference|fact|project [--repo <owner/repo>] [--keep-both] [--json]",
229
249
  "mergestorm memory update <id> <text> [--kind <kind>] [--json]",
230
250
  "mergestorm memory delete <id> [--json]",
231
251
  "mergestorm memory mode [off|on_request|all] [--json] Read or set whether Breeze remembers",
@@ -348,9 +368,12 @@ export function formatUsageBody() {
348
368
  return `${cmdLines}
349
369
 
350
370
  mg is a short alias for mergestorm (same binary).
371
+ --account <name> before or after any command runs it as that saved account
372
+ without changing the active one; MERGESTORM_ACCOUNT=<name> does the same.
351
373
 
352
374
  Env:
353
- MERGESTORM_API_KEY API key (overrides config)
375
+ MERGESTORM_API_KEY API key (overrides every saved account)
376
+ MERGESTORM_ACCOUNT Saved account to run as (default: the active one)
354
377
  MERGESTORM_API_URL API base (default ${DEFAULT_API})
355
378
  MERGESTORM_HOME State directory for login and stack state (default ~/.mergestorm)
356
379
  `;
@@ -366,6 +389,7 @@ export async function dispatchCommand(name, args, ctx) {
366
389
  // `shell` as a subcommand is oneshot-only.
367
390
  if (cmd.name === "shell" && ctx.mode === "shell")
368
391
  return false;
369
- await cmd.run(args, ctx);
392
+ const split = splitAccountFlag(args);
393
+ await withAccount(split.account ?? ctx.account, () => cmd.run(split.args, ctx));
370
394
  return true;
371
395
  }
@@ -10,10 +10,8 @@ export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
10
10
  export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
11
11
  /** Same thread slug contract as POST /api/v1/reviews. */
12
12
  export declare const REVIEW_THREAD_SLUG_RE: RegExp;
13
- export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
14
13
  export declare const MAX_CONTEXT_FILES = 10;
15
14
  export declare const MAX_CONTEXT_FILE_CHARS = 16000;
16
- export declare const MAX_CONTEXT_FILES_TOTAL = 60000;
17
15
  export declare const MAX_IDEMPOTENCY_KEY_CHARS = 128;
18
16
  export type ReviewInput = {
19
17
  thread: string;
@@ -57,7 +55,6 @@ export type LoadReviewContextOptions = {
57
55
  };
58
56
  /** Load --context / --context-file and enforce the API caps client-side. */
59
57
  export declare function loadReviewContext(opts: LoadReviewContextOptions): Promise<ReviewContextPayload>;
60
- export declare function reviewIdempotencyKey(input: ReviewInput, opts?: SubmitReviewOptions, afterJobId?: string | null): string;
61
58
  export declare function submitReview(cfg: Config, input: ReviewInput, opts?: SubmitReviewOptions): Promise<{
62
59
  status: number;
63
60
  row: ReviewJobRow;
@@ -13,10 +13,10 @@ export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
13
13
  export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
14
14
  /** Same thread slug contract as POST /api/v1/reviews. */
15
15
  export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
16
- export const MAX_CONTEXT_TEXT_CHARS = 60_000;
16
+ const MAX_CONTEXT_TEXT_CHARS = 60_000;
17
17
  export const MAX_CONTEXT_FILES = 10;
18
18
  export const MAX_CONTEXT_FILE_CHARS = 16_000;
19
- export const MAX_CONTEXT_FILES_TOTAL = 60_000;
19
+ const MAX_CONTEXT_FILES_TOTAL = 60_000;
20
20
  export const MAX_IDEMPOTENCY_KEY_CHARS = 128;
21
21
  export async function collectReviewInput(base, head, cwd = process.cwd(), sandboxRoot) {
22
22
  let diff;
@@ -129,7 +129,7 @@ export async function loadReviewContext(opts) {
129
129
  }
130
130
  const REPLAYED_JOB_RESUBMIT_STATUSES = ["failed", "quota_exceeded"];
131
131
  const MAX_REPLAYED_JOB_RESUBMITS = 5;
132
- export function reviewIdempotencyKey(input, opts = {}, afterJobId = null) {
132
+ function reviewIdempotencyKey(input, opts = {}, afterJobId = null) {
133
133
  const request = [
134
134
  input.thread, input.branch, input.repo ?? null, input.baseLabel, input.headLabel, input.diff, input.files,
135
135
  opts.routerMode ?? null, [...new Set(opts.specialists ?? [])].sort(), opts.context ?? null, opts.contextFiles ?? [], opts.webhookUrl ?? null,
@@ -10,7 +10,7 @@ export declare const VORTEX_ROUTER_CAPS: {
10
10
  /**
11
11
  * Pinnable L0 lanes only. Keep this list byte-identical to
12
12
  * `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
13
- * (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
13
+ * (`index.test.ts`). Do not add `governance` (round-gated) or `seam`
14
14
  * (phase-gated). Those L1 lanes are not local pins.
15
15
  */
16
16
  export declare const VORTEX_SPECIALIST_IDS: readonly ["security", "performance", "architecture", "tests", "data", "api", "frontend"];
@@ -16,7 +16,7 @@ export const VORTEX_ROUTER_CAPS = { standard: 3, max: 5 };
16
16
  /**
17
17
  * Pinnable L0 lanes only. Keep this list byte-identical to
18
18
  * `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
19
- * (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
19
+ * (`index.test.ts`). Do not add `governance` (round-gated) or `seam`
20
20
  * (phase-gated). Those L1 lanes are not local pins.
21
21
  */
22
22
  export const VORTEX_SPECIALIST_IDS = [
@@ -1,16 +1,16 @@
1
- import { type RepoSettings, type RepoSettingsPatch, type SettingsPatch, type SettingsResponse } from "../api.js";
1
+ import { type RepoSettingsPatch, type SettingsPatch, type SettingsResponse } from "../api.js";
2
2
  /**
3
3
  * CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
4
4
  * read-only on the API and deliberately have no flag here.
5
5
  */
6
- export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
6
+ export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "issue_review_enabled" | "issue_auto_review_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
7
7
  type IgnoreBotOperation = {
8
8
  action: "add" | "remove";
9
9
  login: string;
10
10
  } | {
11
11
  action: "clear";
12
12
  };
13
- export type RepoSettingsScope = {
13
+ type RepoSettingsScope = {
14
14
  owner: string;
15
15
  repo: string;
16
16
  patch: RepoSettingsPatch;
@@ -22,8 +22,6 @@ export type SettingsArgs = {
22
22
  repo?: RepoSettingsScope;
23
23
  listRepos?: boolean;
24
24
  };
25
- export declare function parsePathsFlagValue(flag: string, value: string | undefined, defaults: readonly string[]): string[];
26
- export declare function formatRepoSettingsLines(repos: readonly RepoSettings[]): string[];
27
25
  /** Parse boolean and enum flags plus ordered ignore-list operations. */
28
26
  export declare function parseSettingsArgs(rawArgs: string[]): SettingsArgs;
29
27
  /** Static printout includes the Config toggles plus enum and login settings. */
@@ -18,8 +18,10 @@ const REPO_FLAG_LABELS = {
18
18
  merge_queue_batch_size: "Merge queue batch size (pull requests)",
19
19
  merge_queue_lands_alone_paths: "Merge queue lands-alone paths",
20
20
  merge_queue_overdrive: "Merge queue overdrive",
21
+ issue_review_enabled: "Vortex issue reviews",
22
+ issue_auto_review_enabled: "Vortex issue auto review",
21
23
  };
22
- export function parsePathsFlagValue(flag, value, defaults) {
24
+ function parsePathsFlagValue(flag, value, defaults) {
23
25
  if (value === undefined || value === "" || value.startsWith("--")) {
24
26
  throw new CommandError(`${flag} takes a comma-separated list of path patterns, default, or none.`, 2, "usage");
25
27
  }
@@ -79,7 +81,7 @@ function parseRepoSettingsArgs(slug, args) {
79
81
  throw new CommandError(`Unknown settings flag: ${flag}. See \`mergestorm --help\`.`, 2, "usage");
80
82
  }
81
83
  if (!REPO_SETTINGS_KEYS.includes(key)) {
82
- throw new CommandError(`${flag} is an account setting. With --repo only --auto-patch, --auto-review, --auto-land, --merge-queue-batch, --merge-queue-batch-size, --merge-queue-lands-alone and --merge-queue-overdrive can be set.`, 2, "usage");
84
+ throw new CommandError(`${flag} is an account setting. With --repo only --auto-patch, --auto-review, --auto-land, --merge-queue-batch, --merge-queue-batch-size, --merge-queue-lands-alone, --merge-queue-overdrive, --issue-review and --issue-auto-review can be set.`, 2, "usage");
83
85
  }
84
86
  const value = eq >= 0 ? arg.slice(eq + 1) : args[++i];
85
87
  const row = BEARER_SETTINGS.find((row) => row.key === key);
@@ -115,7 +117,7 @@ function repoSettingWords(value) {
115
117
  return String(value);
116
118
  return value ? "on" : "off";
117
119
  }
118
- export function formatRepoSettingsLines(repos) {
120
+ function formatRepoSettingsLines(repos) {
119
121
  if (repos.length === 0)
120
122
  return [" No repositories are connected to this account."];
121
123
  const width = Math.max(...Object.values(REPO_FLAG_LABELS).map((label) => label.length));
@@ -1,6 +1,6 @@
1
- import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
1
+ import { type StackRepairHint } from "../stack-blockers.js";
2
2
  import { type StackWatchObligation } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getStackPublication, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getIssueReview, type IssueReviewMode, getStackPublication, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { createPrs, findOpenPrNumber } from "../gh.js";
6
6
  import { parseGithubOriginRepo } from "../git.js";
@@ -11,9 +11,7 @@ import { gitTopLevel } from "../repo-identity.js";
11
11
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
12
12
  import { pollStackWatch } from "../stack-watch.js";
13
13
  import { runStackWatch } from "../stack-watch-loop.js";
14
- export declare function stackHeldLine(held: StackHeldBlocker | null | undefined): string[];
15
14
  export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
16
- export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
17
15
  export declare function openStackWatch(stackId: string, opts?: {
18
16
  unread?: boolean;
19
17
  }): StackWatchObligation;
@@ -128,6 +126,22 @@ export type StackCreateDeps = {
128
126
  };
129
127
  export declare function cmdStackCreate(args: string[], deps?: StackCreateDeps): Promise<void>;
130
128
  /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
129
+ export declare function issueReviewModeFor(cfg: Awaited<ReturnType<typeof loadConfig>>, owner: string, repo: string): Promise<IssueReviewMode>;
130
+ export type IssueGateLayer = {
131
+ branch: string;
132
+ texts: string[];
133
+ };
134
+ export type IssueGateDeps = {
135
+ getIssueReview?: typeof getIssueReview;
136
+ issueReviewMode?: typeof issueReviewModeFor;
137
+ };
138
+ export declare function assertLinkedIssuesApproved(input: {
139
+ cfg: Awaited<ReturnType<typeof loadConfig>>;
140
+ owner: string;
141
+ repo: string;
142
+ layers: readonly IssueGateLayer[];
143
+ warn: (line: string) => void;
144
+ }, deps?: IssueGateDeps): Promise<void>;
131
145
  export type StackSubmitDeps = {
132
146
  cwd?: string;
133
147
  gitTopLevel?: (cwd?: string) => string;
@@ -155,7 +169,7 @@ export type StackSubmitDeps = {
155
169
  adoptStack?: typeof adoptStack;
156
170
  ensureUpperPark?: typeof ensureUpperPark;
157
171
  env?: NodeJS.ProcessEnv;
158
- };
172
+ } & IssueGateDeps;
159
173
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
160
174
  export declare function parseStackPublishArgs(args: string[]): {
161
175
  branch?: string;
@@ -1,12 +1,12 @@
1
1
  import { cycloneStatusWords, stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
2
2
  import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getStackPublication, landNextStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, restackStack, setStackPolicy, } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getIssueReview, getIssueReviewMode, issueReviewBlocksPr, getSettings, getStackPublication, issueNotApprovedError, NO_LINKED_ISSUE, landNextStack, listRepoSettings, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, restackStack, setStackPolicy, } from "../api.js";
4
4
  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
8
  import { branchExists, commitsAheadOf, createBranchFrom, createBranchFromHead, trunkStartRef, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
9
- import { buildPrBodyFromCommit } from "../pr-body.js";
9
+ import { buildPrBodyFromCommit, linkedIssueNumbers } from "../pr-body.js";
10
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";
@@ -19,7 +19,7 @@ import { present } from "../ui/present.js";
19
19
  import { canBrowse } from "./browse.js";
20
20
  import { isTransientReviewPollError } from "./review-client.js";
21
21
  import { openHelpBrowser } from "./help.js";
22
- export function stackHeldLine(held) {
22
+ function stackHeldLine(held) {
23
23
  if (!held)
24
24
  return [];
25
25
  const agents = held.waitingOn.map((agent) => agent === "cyclone" ? "Cyclone" : "Vortex").join(" and ");
@@ -32,7 +32,7 @@ export function stackRepairLine(repair) {
32
32
  const note = "note" in repair && repair.note ? ` ${repair.note}` : "";
33
33
  return [`Repair #${repair.prNumber} ${repair.kind}${files}: ${repair.steps}${note}`];
34
34
  }
35
- export function stackWatchTextLines(watch) {
35
+ function stackWatchTextLines(watch) {
36
36
  if (!watch)
37
37
  return [];
38
38
  if (watch.done)
@@ -680,6 +680,71 @@ export async function cmdStackCreate(args, deps = {}) {
680
680
  created.push(ansi.dim(" authoring state: managed automatically outside the repository"));
681
681
  await present("Stack", created);
682
682
  }
683
+ /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
684
+ export async function issueReviewModeFor(cfg, owner, repo) {
685
+ const served = await getIssueReviewMode(cfg, { owner, repo }, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
686
+ if (served)
687
+ return served;
688
+ const repos = await listRepoSettings(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
689
+ const row = repos.find((r) => r.owner.toLowerCase() === owner.toLowerCase() && r.repo.toLowerCase() === repo.toLowerCase());
690
+ const settings = await getSettings(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
691
+ if (!settings)
692
+ throw new CommandError("Could not read the account settings");
693
+ const account = settings;
694
+ const on = typeof row?.issue_review_enabled === "boolean" ? row.issue_review_enabled : account.issue_review_enabled === true;
695
+ if (!on)
696
+ return "off";
697
+ const auto = typeof row?.issue_auto_review_enabled === "boolean" ? row.issue_auto_review_enabled : account.issue_auto_review_enabled !== false;
698
+ return auto ? "auto" : "mention";
699
+ }
700
+ export async function assertLinkedIssuesApproved(input, deps = {}) {
701
+ const { cfg, owner, repo, layers, warn } = input;
702
+ if (layers.length === 0)
703
+ return;
704
+ const getIssueReviewFn = deps.getIssueReview ?? getIssueReview;
705
+ const unverified = (err) => {
706
+ if (err instanceof CommandError && (err.code === "auth_invalid" || err.code === "missing_api_key" || err.code === "rate_limited"))
707
+ return err;
708
+ return new CommandError(`Could not verify the Vortex reviews of the linked issues (${err instanceof Error ? err.message : String(err)}); ` +
709
+ "refusing to submit. Retry when the API is reachable.");
710
+ };
711
+ const linked = layers.map((layer) => ({ branch: layer.branch, numbers: linkedIssueNumbers({ owner, repo }, ...layer.texts) }));
712
+ const statuses = [];
713
+ for (const number of [...new Set(linked.flatMap((layer) => layer.numbers))].sort((a, b) => a - b)) {
714
+ let status;
715
+ try {
716
+ status = await getIssueReviewFn(cfg, { owner, repo, number }, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
717
+ }
718
+ catch (err) {
719
+ throw unverified(err);
720
+ }
721
+ if (!status) {
722
+ warn(ansi.dim(" This Mergestorm server does not check issue reviews yet; submitting without the issue gate."));
723
+ continue;
724
+ }
725
+ statuses.push(status);
726
+ }
727
+ const unlinked = linked.filter((layer) => layer.numbers.length === 0);
728
+ if (unlinked.length > 0) {
729
+ let mode;
730
+ try {
731
+ mode = await (deps.issueReviewMode ?? issueReviewModeFor)(cfg, owner, repo);
732
+ }
733
+ catch (err) {
734
+ throw unverified(err);
735
+ }
736
+ if (mode === "auto") {
737
+ throw new CommandError([
738
+ `Issue reviews are on for ${owner}/${repo}, and ${unlinked.map((layer) => layer.branch).join(", ")} links no issue.`,
739
+ "Open one with `mergestorm issue create --title <problem> --wait`, reply once to any blocking points in the Vortex review,",
740
+ "then add `Fixes #N` to the tip commit message and submit again.",
741
+ ].join("\n"), REVIEW_EXIT.failed, "issue_not_approved", { reason: NO_LINKED_ISSUE });
742
+ }
743
+ }
744
+ const blocking = statuses.filter(issueReviewBlocksPr);
745
+ if (blocking.length > 0)
746
+ throw issueNotApprovedError(blocking);
747
+ }
683
748
  export async function cmdStackSubmit(args, deps = {}) {
684
749
  const { extend, autoLand, autoReview, autoPatch, autoResolveConflicts, autoFixCi, rest, serverSide, asJson } = parseStackSubmitArgs(args);
685
750
  const api = rest ? "rest" : githubApiFromEnv(deps.env ?? process.env);
@@ -803,6 +868,21 @@ export async function cmdStackSubmit(args, deps = {}) {
803
868
  for (const plan of plans) {
804
869
  existingPrs.set(plan.branch, await opener.findOpen(plan.branch));
805
870
  }
871
+ await assertLinkedIssuesApproved({
872
+ cfg: await loadConfigFn(),
873
+ owner,
874
+ repo,
875
+ layers: plans
876
+ .filter((plan) => existingPrs.get(plan.branch) == null)
877
+ .map((plan) => {
878
+ if (!branchExistsFn(plan.branch, cwd)) {
879
+ throw new CommandError(`Stack layer branch missing locally: ${plan.branch}`);
880
+ }
881
+ const message = tipCommitMessageFn(plan.branch, cwd);
882
+ return { branch: plan.branch, texts: [message, buildPrBodyFn(message)] };
883
+ }),
884
+ warn: progress,
885
+ }, deps);
806
886
  const newHeads = plans
807
887
  .filter((plan) => existingPrs.get(plan.branch) == null)
808
888
  .map((plan) => plan.branch);
@@ -1487,11 +1567,15 @@ async function cmdStackLand(args) {
1487
1567
  ]);
1488
1568
  return;
1489
1569
  }
1570
+ const cascadeFailed = body.cascadeFailed;
1490
1571
  await present("Stack", [
1491
1572
  ansi.brightGreen(` Landed` +
1492
1573
  (pr != null ? ` #${pr}` : "") +
1493
1574
  (branch ? ` (${branch})` : "") +
1494
1575
  ` on ${id}`),
1576
+ ...(cascadeFailed
1577
+ ? [ansi.yellow(` The layers above it were not updated: ${cascadeFailed}`)]
1578
+ : []),
1495
1579
  ]);
1496
1580
  }
1497
1581
  export async function cmdStack(args, options = {}) {