mergestorm 0.3.13 → 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 (60) hide show
  1. package/README.md +14 -15
  2. package/dist/api.d.ts +82 -5
  3. package/dist/api.js +166 -7
  4. package/dist/client/index.d.ts +2 -2
  5. package/dist/client/index.js +2 -2
  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 +7 -10
  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 +4 -3
  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.js +93 -18
  22. package/dist/commands/review-client.d.ts +13 -0
  23. package/dist/commands/review-client.js +110 -0
  24. package/dist/commands/review.d.ts +6 -0
  25. package/dist/commands/review.js +18 -7
  26. package/dist/commands/settings.d.ts +17 -0
  27. package/dist/commands/settings.js +89 -0
  28. package/dist/commands/skill.d.ts +20 -0
  29. package/dist/commands/skill.js +145 -0
  30. package/dist/commands/stack.d.ts +2 -0
  31. package/dist/commands/stack.js +91 -57
  32. package/dist/commands/status.d.ts +2 -0
  33. package/dist/commands/status.js +30 -15
  34. package/dist/commands/whoami.d.ts +3 -1
  35. package/dist/commands/whoami.js +21 -7
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/shell.js +39 -31
  38. package/dist/stack-dto.d.ts +82 -1
  39. package/dist/ui/ansi.d.ts +2 -0
  40. package/dist/ui/ansi.js +2 -0
  41. package/dist/ui/banner.d.ts +34 -2
  42. package/dist/ui/banner.js +163 -79
  43. package/dist/ui/findings.d.ts +2 -1
  44. package/dist/ui/findings.js +13 -7
  45. package/dist/ui/line-tabs.d.ts +27 -0
  46. package/dist/ui/line-tabs.js +170 -0
  47. package/dist/ui/logo.d.ts +10 -2
  48. package/dist/ui/logo.js +13 -9
  49. package/dist/ui/present.d.ts +10 -0
  50. package/dist/ui/present.js +13 -0
  51. package/dist/ui/prompt.d.ts +58 -1
  52. package/dist/ui/prompt.js +225 -42
  53. package/dist/ui/select.js +1 -1
  54. package/dist/ui/tabs.d.ts +125 -0
  55. package/dist/ui/tabs.js +480 -0
  56. package/dist/ui/usage.d.ts +4 -0
  57. package/dist/ui/usage.js +22 -2
  58. package/package.json +2 -1
  59. package/skill/SKILL.md +98 -0
  60. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
@@ -0,0 +1,127 @@
1
+ import { getPrVortexReview } from "../api.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { CommandError, REVIEW_EXIT } from "../errors.js";
4
+ import { ansi } from "../ui/ansi.js";
5
+ import { renderFindings } from "../ui/findings.js";
6
+ import { PrReviewPollTimeoutError, REVIEW_POLL_DEFAULT_TIMEOUT_MS, pollPrVortexReview, } from "./review-client.js";
7
+ import { parseAdoptTarget } from "./stack.js";
8
+ const PR_USAGE = "usage: mergestorm pr <owner/repo>#<n> [--json] [--wait] [--after-sha <sha>] [--timeout <s>]";
9
+ function usageError(message = PR_USAGE) {
10
+ return new CommandError(message, REVIEW_EXIT.usage, "usage");
11
+ }
12
+ export function parsePrArgs(args, defaults = { format: "json" }) {
13
+ const target = [];
14
+ let format = defaults.format;
15
+ let wait = false;
16
+ let afterSha;
17
+ let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
18
+ for (let i = 0; i < args.length; i += 1) {
19
+ const arg = args[i];
20
+ if (arg === "--json") {
21
+ format = "json";
22
+ continue;
23
+ }
24
+ if (arg === "--pretty") {
25
+ format = "pretty";
26
+ continue;
27
+ }
28
+ if (arg === "--wait") {
29
+ wait = true;
30
+ continue;
31
+ }
32
+ if (arg === "--after-sha" || arg.startsWith("--after-sha=")) {
33
+ const value = arg === "--after-sha" ? args[++i] : arg.slice("--after-sha=".length);
34
+ if (!value?.trim())
35
+ throw usageError("--after-sha requires a non-empty SHA");
36
+ afterSha = value.trim();
37
+ if (afterSha.length < 7) {
38
+ throw usageError("--after-sha must be at least 7 characters");
39
+ }
40
+ if (!/^[0-9a-f]+$/i.test(afterSha)) {
41
+ throw usageError("--after-sha must contain only hexadecimal characters");
42
+ }
43
+ continue;
44
+ }
45
+ if (arg === "--timeout" || arg.startsWith("--timeout=")) {
46
+ const value = arg === "--timeout" ? args[++i] : arg.slice("--timeout=".length);
47
+ const seconds = Number(value);
48
+ if (!value || !Number.isFinite(seconds) || seconds <= 0) {
49
+ throw usageError("--timeout must be a positive number of seconds");
50
+ }
51
+ timeoutMs = Math.round(seconds * 1_000);
52
+ wait = true;
53
+ continue;
54
+ }
55
+ if (arg.startsWith("-"))
56
+ throw usageError(`unknown flag: ${arg}`);
57
+ target.push(arg);
58
+ }
59
+ if (afterSha && !wait)
60
+ throw usageError("--after-sha requires --wait");
61
+ if (target.length < 1 || target.length > 2)
62
+ throw usageError();
63
+ let parsed;
64
+ try {
65
+ parsed = parseAdoptTarget(target);
66
+ }
67
+ catch {
68
+ throw usageError();
69
+ }
70
+ return { ...parsed, format, wait, afterSha, timeoutMs };
71
+ }
72
+ function printPretty(envelope) {
73
+ console.log(`Status: ${envelope.status}`);
74
+ if (envelope.verdict)
75
+ console.log(`Verdict: ${ansi.bold(envelope.verdict)}`);
76
+ if (envelope.head_sha)
77
+ console.log(`Head: ${envelope.head_sha}`);
78
+ console.log(`Finding count: ${envelope.finding_count}`);
79
+ if (envelope.skip_reason)
80
+ console.log(`Skip reason: ${envelope.skip_reason}`);
81
+ if (envelope.summary)
82
+ console.log(envelope.summary);
83
+ renderFindings(envelope.findings, {
84
+ emptyMessage: envelope.status === "completed",
85
+ });
86
+ }
87
+ export async function cmdPr(args, opts = {}) {
88
+ const parsed = parsePrArgs(args, {
89
+ format: opts.defaultFormat ?? "json",
90
+ });
91
+ const cfg = await loadConfig();
92
+ let envelope;
93
+ if (parsed.wait) {
94
+ try {
95
+ envelope = await pollPrVortexReview(cfg, parsed.owner, parsed.repo, parsed.prNumber, {
96
+ timeoutMs: parsed.timeoutMs,
97
+ intervalMs: opts.pollIntervalMs,
98
+ afterSha: parsed.afterSha,
99
+ signal: opts.signal,
100
+ ...opts.poll,
101
+ onTick(kind) {
102
+ if (parsed.format === "json")
103
+ console.error(kind === "retry" ? "?" : ".");
104
+ else
105
+ process.stdout.write(kind === "retry" ? "?" : ".");
106
+ },
107
+ });
108
+ }
109
+ catch (err) {
110
+ if (err instanceof PrReviewPollTimeoutError) {
111
+ throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
112
+ }
113
+ throw err;
114
+ }
115
+ if (parsed.format === "pretty")
116
+ console.log("");
117
+ }
118
+ else {
119
+ envelope = await getPrVortexReview(parsed.owner, parsed.repo, parsed.prNumber, cfg, { signal: opts.signal });
120
+ }
121
+ if (parsed.format === "json") {
122
+ console.log(JSON.stringify(envelope, null, opts.mode === "shell" ? 2 : undefined));
123
+ }
124
+ else {
125
+ printPretty(envelope);
126
+ }
127
+ }
@@ -0,0 +1,12 @@
1
+ import { type MergeQueueEntryDto } from "../api.js";
2
+ import { runLineTabsBrowser } from "../ui/line-tabs.js";
3
+ /** Injectable seams for unit tests. Production callers omit deps. */
4
+ export type QueueCmdDeps = {
5
+ canBrowse?: () => boolean;
6
+ runLineTabsBrowser?: typeof runLineTabsBrowser;
7
+ };
8
+ export declare const QUEUE_USAGE = "usage:\n mergestorm queue [list] [--json]\n mergestorm queue add <stack-id> [--json]\n mergestorm queue rm <entry-id|stack-id> [--json]\n\n queue lists your live merge-queue entries. rm prefers an entry id; a live\n stack id is resolved to its queue entry before cancellation.";
9
+ /** Entry ids win; otherwise a live stack id resolves to its entry id. */
10
+ export declare function resolveQueueEntryId(idOrStackId: string, entries: readonly MergeQueueEntryDto[]): string;
11
+ export declare function buildQueueListLines(entries: readonly MergeQueueEntryDto[], fullStackId?: boolean): string[];
12
+ export declare function cmdQueue(args: string[], deps?: QueueCmdDeps): Promise<void>;
@@ -0,0 +1,160 @@
1
+ import { cancelMergeQueueEntry, enqueueStack, listMergeQueueEntries, } from "../api.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { CommandError } from "../errors.js";
4
+ import { ansi } from "../ui/ansi.js";
5
+ import { runLineTabsBrowser } from "../ui/line-tabs.js";
6
+ import { present } from "../ui/present.js";
7
+ import { visibleWidth } from "../ui/width.js";
8
+ import { canBrowse } from "./browse.js";
9
+ const QUEUE_STATE_GLYPHS = {
10
+ queued: "○",
11
+ running: "●",
12
+ waiting: "◐",
13
+ bounced: "×",
14
+ landed: "✓",
15
+ cancelled: "·",
16
+ };
17
+ function queueStateGlyph(state) {
18
+ return QUEUE_STATE_GLYPHS[state] ?? "·";
19
+ }
20
+ function colorQueueGlyph(state, glyph) {
21
+ if (state === "running")
22
+ return ansi.brightGreen(glyph);
23
+ if (state === "waiting")
24
+ return ansi.yellow(glyph);
25
+ if (state === "bounced")
26
+ return ansi.red(glyph);
27
+ return ansi.dim(glyph);
28
+ }
29
+ export const QUEUE_USAGE = `usage:
30
+ mergestorm queue [list] [--json]
31
+ mergestorm queue add <stack-id> [--json]
32
+ mergestorm queue rm <entry-id|stack-id> [--json]
33
+
34
+ queue lists your live merge-queue entries. rm prefers an entry id; a live
35
+ stack id is resolved to its queue entry before cancellation.`;
36
+ function positionalArgs(args) {
37
+ const unknown = args.filter((arg) => arg.startsWith("-") &&
38
+ arg !== "--json" &&
39
+ arg !== "-h" &&
40
+ arg !== "--help");
41
+ if (unknown.length > 0) {
42
+ throw new CommandError(`unknown queue option: ${unknown[0]}`, 2, "usage");
43
+ }
44
+ return args.filter((arg) => arg !== "--json");
45
+ }
46
+ function requireIdentifier(value, usage) {
47
+ const id = value?.trim();
48
+ if (!id)
49
+ throw new CommandError(usage, 2, "usage");
50
+ return id;
51
+ }
52
+ /** Entry ids win; otherwise a live stack id resolves to its entry id. */
53
+ export function resolveQueueEntryId(idOrStackId, entries) {
54
+ const entry = entries.find((candidate) => candidate.id === idOrStackId);
55
+ if (entry)
56
+ return entry.id;
57
+ const stackEntry = entries.find((candidate) => candidate.stackId === idOrStackId);
58
+ return stackEntry?.id ?? idOrStackId;
59
+ }
60
+ export function buildQueueListLines(entries, fullStackId = false) {
61
+ if (entries.length === 0) {
62
+ return [
63
+ ` ${ansi.bold("Nothing queued")}`,
64
+ "",
65
+ ` ${ansi.brightGreen("queue add <stack-id>")} send a stack for verified landing`,
66
+ ];
67
+ }
68
+ return entries.map((entry) => {
69
+ const reason = entry.waitReason ?? entry.bounceReason;
70
+ const glyph = colorQueueGlyph(entry.state, queueStateGlyph(entry.state));
71
+ const id = fullStackId ? entry.stackId : entry.stackId.slice(0, 8);
72
+ const core = ` ${String(entry.position).padStart(2)} ` +
73
+ `${glyph} ${entry.state.padEnd(8)} ${entry.owner}/${entry.repo} ${id}` +
74
+ (reason ? ` ${reason}` : "");
75
+ const who = entry.enqueuedBy === "human" || entry.enqueuedBy === "agent"
76
+ ? ` by ${entry.enqueuedBy}`
77
+ : "";
78
+ const withWho = `${core}${who}`;
79
+ return visibleWidth(withWho) <= 75 ? withWho : core;
80
+ });
81
+ }
82
+ async function listQueue(asJson, deps = {}) {
83
+ const cfg = await loadConfig();
84
+ const entries = await listMergeQueueEntries(cfg);
85
+ if (asJson) {
86
+ console.log(JSON.stringify({ entries }, null, 2));
87
+ return;
88
+ }
89
+ const browse = deps.canBrowse ?? canBrowse;
90
+ const browseable = browse();
91
+ const lines = buildQueueListLines(entries, !browseable);
92
+ if (browseable) {
93
+ const runBrowser = deps.runLineTabsBrowser ?? runLineTabsBrowser;
94
+ await runBrowser({
95
+ tabs: [{ id: "queue", label: "Queue", lines }],
96
+ });
97
+ return;
98
+ }
99
+ await present("Merge queue", lines);
100
+ }
101
+ async function addQueue(args, asJson) {
102
+ const id = requireIdentifier(args[0], "usage: mergestorm queue add <stack-id> [--json]");
103
+ if (args.length > 1) {
104
+ throw new CommandError("usage: mergestorm queue add <stack-id> [--json]", 2, "usage");
105
+ }
106
+ const cfg = await loadConfig();
107
+ const body = await enqueueStack(id, cfg);
108
+ if (asJson) {
109
+ console.log(JSON.stringify(body, null, 2));
110
+ return;
111
+ }
112
+ const entry = body?.entry;
113
+ const duplicate = body?.duplicate === true;
114
+ await present("Merge queue", [
115
+ ansi.brightGreen(` ${duplicate ? "Already queued" : "Queued"} ${id}` +
116
+ (entry?.position ? ` at position ${entry.position}` : "")),
117
+ ]);
118
+ }
119
+ async function removeQueue(args, asJson) {
120
+ const requested = requireIdentifier(args[0], "usage: mergestorm queue rm <entry-id|stack-id> [--json]");
121
+ if (args.length > 1) {
122
+ throw new CommandError("usage: mergestorm queue rm <entry-id|stack-id> [--json]", 2, "usage");
123
+ }
124
+ const cfg = await loadConfig();
125
+ const entries = await listMergeQueueEntries(cfg);
126
+ const entryId = resolveQueueEntryId(requested, entries);
127
+ const body = await cancelMergeQueueEntry(entryId, cfg);
128
+ if (asJson) {
129
+ console.log(JSON.stringify(body, null, 2));
130
+ return;
131
+ }
132
+ await present("Merge queue", [
133
+ ansi.brightGreen(` Removed ${entryId} from the merge queue`),
134
+ ]);
135
+ }
136
+ export async function cmdQueue(args, deps = {}) {
137
+ const asJson = args.includes("--json");
138
+ const positional = positionalArgs(args);
139
+ const sub = positional[0]?.toLowerCase();
140
+ if (sub === "-h" || sub === "--help" || sub === "help") {
141
+ console.log(QUEUE_USAGE);
142
+ return;
143
+ }
144
+ if (!sub || sub === "list" || sub === "ls") {
145
+ if (positional.length > (sub ? 1 : 0)) {
146
+ throw new CommandError(QUEUE_USAGE, 2, "usage");
147
+ }
148
+ await listQueue(asJson, deps);
149
+ return;
150
+ }
151
+ if (sub === "add") {
152
+ await addQueue(positional.slice(1), asJson);
153
+ return;
154
+ }
155
+ if (sub === "rm" || sub === "remove") {
156
+ await removeQueue(positional.slice(1), asJson);
157
+ return;
158
+ }
159
+ throw new CommandError(`${QUEUE_USAGE}\nunknown queue subcommand: ${sub}`);
160
+ }
@@ -1,11 +1,16 @@
1
1
  import { DEFAULT_API } from "../config.js";
2
2
  import { CommandError } from "../errors.js";
3
3
  import { cmdBranches, cmdChain } from "./branches.js";
4
+ import { canBrowse, openTabsBrowser } from "./browse.js";
4
5
  import { cmdCredits } from "./credits.js";
5
6
  import { cmdJobs, cmdThread } from "./jobs.js";
6
7
  import { cmdLogin } from "./login.js";
7
8
  import { cmdLogout } from "./logout.js";
9
+ import { cmdQueue } from "./queue.js";
10
+ import { cmdPr } from "./pr.js";
8
11
  import { cmdReview } from "./review.js";
12
+ import { cmdSettings } from "./settings.js";
13
+ import { cmdSkill } from "./skill.js";
9
14
  import { cmdStack } from "./stack.js";
10
15
  import { cmdStatus } from "./status.js";
11
16
  import { cmdWhoami } from "./whoami.js";
@@ -56,20 +61,42 @@ export const COMMAND_REGISTRY = [
56
61
  await cmdReview(args);
57
62
  },
58
63
  },
64
+ {
65
+ name: "pr",
66
+ summary: "Latest Vortex review for a GitHub PR",
67
+ usage: [
68
+ "mergestorm pr <owner/repo>#<n> Fetch the latest Vortex PR review",
69
+ " [--json] [--wait] [--after-sha <sha>] [--timeout <s>]",
70
+ " Also accepts: mergestorm pr <owner/repo> <n>",
71
+ ],
72
+ async run(args, ctx) {
73
+ await cmdPr(args, {
74
+ defaultFormat: ctx.mode === "shell" ? "pretty" : "json",
75
+ signal: ctx.signal,
76
+ mode: ctx.mode,
77
+ });
78
+ },
79
+ },
59
80
  {
60
81
  name: "status",
61
- summary: "Show a review job",
82
+ summary: "Job panel (id) or Status / Usage / Jobs tabs",
62
83
  usage: [
63
84
  "mergestorm status <job_id> Fetch a review job (same envelope as review --json)",
64
85
  " [--json] [--wait] [--timeout seconds]",
65
86
  " HTTP 429 exits 7 (rate_limited) with retry_after_seconds",
87
+ " With no job id on a TTY: tabbed Status / Usage / Jobs / Config browser",
66
88
  ],
67
89
  async run(args, ctx) {
90
+ if (args.length === 0 && canBrowse()) {
91
+ await openTabsBrowser("status");
92
+ return;
93
+ }
68
94
  if (ctx.mode === "shell") {
69
95
  await cmdStatus(args, {
70
96
  defaultFormat: "pretty",
71
97
  signal: ctx.signal,
72
98
  interactive: true,
99
+ mode: "shell",
73
100
  });
74
101
  return;
75
102
  }
@@ -79,8 +106,11 @@ export const COMMAND_REGISTRY = [
79
106
  {
80
107
  name: "credits",
81
108
  aliases: ["usage"],
82
- summary: "Usage panel: credit bar and last five jobs",
83
- usage: ["mergestorm credits [--json] Usage panel (bar + last 5 jobs)"],
109
+ summary: "Usage: tabbed browser (TTY) or static panel",
110
+ usage: [
111
+ "mergestorm credits [--json] Usage panel (bar + last 5 jobs)",
112
+ " On a TTY: tabbed Status / Usage / Jobs / Config browser (Usage selected)",
113
+ ],
84
114
  async run(args) {
85
115
  await cmdCredits(args);
86
116
  },
@@ -89,8 +119,14 @@ export const COMMAND_REGISTRY = [
89
119
  name: "jobs",
90
120
  summary: "Recent review jobs (default 10)",
91
121
  usage: ["mergestorm jobs [n] [--json] Recent review jobs"],
92
- async run(args) {
93
- await cmdJobs(args);
122
+ async run(args, ctx) {
123
+ if (args.every((a) => a !== "--json") &&
124
+ !args.some((a) => /^\d+$/.test(a)) &&
125
+ canBrowse()) {
126
+ await openTabsBrowser("jobs");
127
+ return;
128
+ }
129
+ await cmdJobs(args, { mode: ctx.mode });
94
130
  },
95
131
  },
96
132
  {
@@ -98,26 +134,51 @@ export const COMMAND_REGISTRY = [
98
134
  aliases: ["chains"],
99
135
  summary: "Pick a recently reviewed branch",
100
136
  usage: ["mergestorm branches [n] Recently reviewed branches (pick on TTY)"],
101
- async run(args) {
102
- await cmdBranches(args);
137
+ async run(args, ctx) {
138
+ await cmdBranches(args, { mode: ctx.mode });
103
139
  },
104
140
  },
105
141
  {
106
142
  name: "chain",
107
- summary: "Show a branch review-chain timeline",
143
+ summary: "Branch review-chain timeline (holds until q)",
108
144
  usage: [
109
145
  "mergestorm chain [slug] Branch review-chain timeline (default: current)",
110
146
  ],
111
- async run(args) {
112
- await cmdChain(args);
147
+ async run(args, ctx) {
148
+ await cmdChain(args, { mode: ctx.mode });
113
149
  },
114
150
  },
115
151
  {
116
152
  name: "whoami",
117
- summary: "Key + plan + API base",
153
+ summary: "Key + plan (Status tab on a TTY)",
118
154
  usage: ["mergestorm whoami [--json] Key + plan + API base"],
155
+ async run(args, ctx) {
156
+ await cmdWhoami(args, { mode: ctx.mode });
157
+ },
158
+ },
159
+ {
160
+ name: "settings",
161
+ summary: "Automation toggles (Config tab on a TTY)",
162
+ usage: [
163
+ "mergestorm settings [--json] Read the automation toggles (Config tab on a TTY)",
164
+ " [--auto-review on|off] [--auto-patch on|off] [--vortex-thinking on|off]",
165
+ " [--repo-overview on|off] [--review-unit-land on|off]",
166
+ " [--cyclone-review-unit-land on|off] [--vortex-seam on|off]",
167
+ " With flags: PATCH those settings and print the stored result",
168
+ ],
169
+ async run(args, ctx) {
170
+ await cmdSettings(args, { mode: ctx.mode });
171
+ },
172
+ },
173
+ {
174
+ name: "skill",
175
+ summary: "Install mergestorm-review and mergestorm-pr-loop for Claude or Cursor",
176
+ usage: [
177
+ "mergestorm skill install --claude|--cursor Copy the mergestorm-review and mergestorm-pr-loop skills into this repo",
178
+ " [--json]",
179
+ ],
119
180
  async run(args) {
120
- await cmdWhoami(args);
181
+ await cmdSkill(args);
121
182
  },
122
183
  },
123
184
  {
@@ -130,12 +191,24 @@ export const COMMAND_REGISTRY = [
130
191
  ? "usage: thread <slug>"
131
192
  : "usage: mergestorm thread <slug>");
132
193
  }
133
- await cmdThread(args[0], args.slice(1));
194
+ await cmdThread(args[0], args.slice(1), { mode: ctx.mode });
195
+ },
196
+ },
197
+ {
198
+ name: "queue",
199
+ summary: "Merge queue: list, add a stack, or remove an entry",
200
+ usage: [
201
+ "mergestorm queue [list] [--json] List live merge-queue entries",
202
+ "mergestorm queue add <stack-id> [--json] Queue a stack for verified landing",
203
+ "mergestorm queue rm <entry-id|stack-id> [--json] Remove a live queue entry",
204
+ ],
205
+ async run(args) {
206
+ await cmdQueue(args);
134
207
  },
135
208
  },
136
209
  {
137
210
  name: "stack",
138
- summary: "Stacks: create, submit, list, adopt, restack, land",
211
+ summary: "Stacks: /stack how-to, list yours, create → submit",
139
212
  usage: [
140
213
  "mergestorm stack create [name] New local stack layer ([--onto] [--trunk] [--extend])",
141
214
  "mergestorm stack submit Push active local stack, open PRs ([--extend])",
@@ -144,7 +217,6 @@ export const COMMAND_REGISTRY = [
144
217
  "mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain",
145
218
  "mergestorm stack restack <stack-id> Restack stack descendants",
146
219
  "mergestorm stack land <stack-id> Land bottom PR (or promote into unit)",
147
- "mergestorm stack auto-promote on|off <stack-id> Toggle auto-promote when green",
148
220
  ],
149
221
  async run(args) {
150
222
  await cmdStack(args);
@@ -163,7 +235,7 @@ export const COMMAND_REGISTRY = [
163
235
  ];
164
236
  /** Shell meta commands (not dispatched via {@link COMMAND_REGISTRY}). */
165
237
  const SHELL_META = [
166
- { name: "help", summary: "Show available commands" },
238
+ { name: "help", summary: "Tabbed help (Start / Commands / Stacks)" },
167
239
  { name: "clear", summary: "Clear screen and reprint banner" },
168
240
  { name: "exit", summary: "Leave the shell" },
169
241
  { name: "quit", summary: "Leave the shell" },
@@ -176,7 +248,10 @@ export function findCommand(name) {
176
248
  }
177
249
  /** Autocomplete / `/help` list for the interactive shell. Aliases fold onto the primary name. */
178
250
  export function shellCommandSpecs() {
179
- const specs = [{ name: "help", summary: SHELL_META[0].summary }];
251
+ const specs = [
252
+ { name: "help", summary: SHELL_META[0].summary },
253
+ { name: "exit", summary: SHELL_META[2].summary, aliases: ["quit"] },
254
+ ];
180
255
  for (const c of COMMAND_REGISTRY) {
181
256
  if (SHELL_OMIT.has(c.name))
182
257
  continue;
@@ -186,7 +261,7 @@ export function shellCommandSpecs() {
186
261
  ...(c.aliases?.length ? { aliases: c.aliases } : {}),
187
262
  });
188
263
  }
189
- specs.push({ name: "clear", summary: SHELL_META[1].summary }, { name: "exit", summary: SHELL_META[2].summary, aliases: ["quit"] });
264
+ specs.push({ name: "clear", summary: SHELL_META[1].summary });
190
265
  return specs;
191
266
  }
192
267
  export function formatUsageBody() {
@@ -1,3 +1,4 @@
1
+ import { type PrVortexReview } from "../api.js";
1
2
  import type { Config } from "../config.js";
2
3
  import type { ReviewJobRow } from "../ui/job-envelope.js";
3
4
  export declare const REVIEW_POLL_INTERVAL_MS = 2000;
@@ -76,6 +77,13 @@ export declare class ReviewPollTimeoutError extends Error {
76
77
  readonly lastRow: ReviewJobRow;
77
78
  constructor(jobId: string, lastRow: ReviewJobRow);
78
79
  }
80
+ export declare class PrReviewPollTimeoutError extends Error {
81
+ readonly owner: string;
82
+ readonly repo: string;
83
+ readonly prNumber: number;
84
+ readonly lastEnvelope: PrVortexReview | null;
85
+ constructor(owner: string, repo: string, prNumber: number, lastEnvelope: PrVortexReview | null);
86
+ }
79
87
  export type PollReviewOptions = {
80
88
  timeoutMs?: number;
81
89
  intervalMs?: number;
@@ -90,4 +98,9 @@ export type PollReviewOptions = {
90
98
  /** Test seam for backoff jitter; production uses `Math.random`. */
91
99
  random?: () => number;
92
100
  };
101
+ export type PollPrVortexReviewOptions = PollReviewOptions & {
102
+ afterSha?: string;
103
+ };
104
+ /** Poll the DB-only PR review endpoint until the requested pass is resting. */
105
+ export declare function pollPrVortexReview(cfg: Config, owner: string, repo: string, prNumber: number, opts?: PollPrVortexReviewOptions): Promise<PrVortexReview>;
93
106
  export declare function pollReview(cfg: Config, jobId: string, opts?: PollReviewOptions): Promise<ReviewJobRow>;
@@ -210,6 +210,116 @@ export class ReviewPollTimeoutError extends Error {
210
210
  this.lastRow = lastRow;
211
211
  }
212
212
  }
213
+ export class PrReviewPollTimeoutError extends Error {
214
+ owner;
215
+ repo;
216
+ prNumber;
217
+ lastEnvelope;
218
+ constructor(owner, repo, prNumber, lastEnvelope) {
219
+ super(`Timed out waiting for PR review ${owner}/${repo}#${prNumber}`);
220
+ this.name = "PrReviewPollTimeoutError";
221
+ this.owner = owner;
222
+ this.repo = repo;
223
+ this.prNumber = prNumber;
224
+ this.lastEnvelope = lastEnvelope;
225
+ }
226
+ }
227
+ function shaMatches(actual, expected) {
228
+ const left = actual?.trim().toLowerCase() ?? "";
229
+ const right = expected.trim().toLowerCase();
230
+ return Boolean(left && right.length >= 7 && (left.startsWith(right) || right.startsWith(left)));
231
+ }
232
+ /** Poll the DB-only PR review endpoint until the requested pass is resting. */
233
+ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {}) {
234
+ const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
235
+ const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
236
+ const now = opts.now ?? Date.now;
237
+ const wait = opts.sleep ?? sleep;
238
+ const startedAt = now();
239
+ const deadline = startedAt + timeoutMs;
240
+ const query = new URLSearchParams({
241
+ owner,
242
+ repo,
243
+ pr_number: String(prNumber),
244
+ });
245
+ if (opts.afterSha)
246
+ query.set("after_sha", opts.afterSha);
247
+ let transientFailures = 0;
248
+ let lastEnvelope = null;
249
+ const sleepBeforeRetry = async (retryAfterSeconds) => {
250
+ const waitMs = Math.min(transientRetryWaitMs({
251
+ retryAfterSeconds,
252
+ failureCount: transientFailures,
253
+ random: opts.random,
254
+ }), Math.max(0, deadline - now()));
255
+ if (waitMs > 0)
256
+ await wait(waitMs, opts.signal);
257
+ };
258
+ const sleepBeforePoll = async () => {
259
+ const waitMs = Math.min(stretchedPollIntervalMs(now() - startedAt, intervalMs), Math.max(0, deadline - now()));
260
+ if (waitMs > 0)
261
+ await wait(waitMs, opts.signal);
262
+ };
263
+ while (now() < deadline) {
264
+ let poll;
265
+ try {
266
+ poll = await apiFetch(cfg, `/api/v1/stacks/pr-review?${query.toString()}`, {
267
+ signal: opts.signal,
268
+ timeoutMs: Math.max(1, Math.min(30_000, deadline - now())),
269
+ });
270
+ }
271
+ catch (err) {
272
+ if (isTransientReviewPollError(err) &&
273
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
274
+ transientFailures += 1;
275
+ opts.onTick?.("retry");
276
+ await sleepBeforeRetry();
277
+ continue;
278
+ }
279
+ if (isCommandErrorCode(err, "api_timeout")) {
280
+ if (opts.propagateTimeout)
281
+ throw err;
282
+ break;
283
+ }
284
+ if (isTransientReviewPollError(err)) {
285
+ const detail = err instanceof Error ? err.message : String(err);
286
+ throw new CommandError(`Poll failed: ${detail}`, REVIEW_EXIT.failed, "review_failed");
287
+ }
288
+ throw err;
289
+ }
290
+ if (poll.status === 404) {
291
+ transientFailures = 0;
292
+ opts.onTick?.("progress");
293
+ await sleepBeforePoll();
294
+ continue;
295
+ }
296
+ if (poll.status !== 200) {
297
+ if (isTransientReviewPollStatus(poll.status) &&
298
+ transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
299
+ transientFailures += 1;
300
+ opts.onTick?.("retry");
301
+ await sleepBeforeRetry(poll.retryAfterSeconds);
302
+ continue;
303
+ }
304
+ if (poll.status === 429) {
305
+ throw new CommandError(rateLimitedMessage(poll.retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds: poll.retryAfterSeconds });
306
+ }
307
+ throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, REVIEW_EXIT.failed, "review_failed");
308
+ }
309
+ transientFailures = 0;
310
+ if (!poll.body || typeof poll.body !== "object") {
311
+ throw new CommandError("Poll failed: invalid PR review response", REVIEW_EXIT.failed, "review_failed");
312
+ }
313
+ lastEnvelope = poll.body;
314
+ const resting = (lastEnvelope.raw_status ?? lastEnvelope.status) !== "in_progress";
315
+ const matches = !opts.afterSha || shaMatches(lastEnvelope.head_sha, opts.afterSha);
316
+ if (resting && matches)
317
+ return lastEnvelope;
318
+ opts.onTick?.("progress");
319
+ await sleepBeforePoll();
320
+ }
321
+ throw new PrReviewPollTimeoutError(owner, repo, prNumber, lastEnvelope);
322
+ }
213
323
  export async function pollReview(cfg, jobId, opts = {}) {
214
324
  const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
215
325
  const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
@@ -7,6 +7,12 @@ export declare const VORTEX_ROUTER_CAPS: {
7
7
  readonly standard: 3;
8
8
  readonly max: 5;
9
9
  };
10
+ /**
11
+ * Pinnable L0 lanes only. Keep this list byte-identical to
12
+ * `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
13
+ * (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
14
+ * (phase-gated). Those L1 lanes are not local pins.
15
+ */
10
16
  export declare const VORTEX_SPECIALIST_IDS: readonly ["security", "performance", "architecture", "tests", "data", "api", "frontend"];
11
17
  type VortexRouterMode = (typeof VORTEX_ROUTER_MODES)[number];
12
18
  type VortexSpecialistId = (typeof VORTEX_SPECIALIST_IDS)[number];