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
package/README.md CHANGED
@@ -41,18 +41,16 @@ node dist/cli.js --help
41
41
 
42
42
  ## Interactive shell
43
43
 
44
- On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a **full-width** welcome panel (logo + status on the left, tips / what's new on the right) and a **full-width** bordered input box with a live `/` slash-command dropdown. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
44
+ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a compact **full-width** welcome panel (mark, version, status, usage bar) and a **full-width** bordered input box with a live `/` slash-command dropdown. The welcome panel reflows on terminal resize instead of wrapping or smearing. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
45
45
 
46
46
  ```
47
- ╭─ mergestorm v0.3.13 ─────────────────────────────────────────────────────────╮
48
- │ ▟██████████▛ Tips for getting started │
49
- │ ▜████████▛ review review origin/HEAD or main │
50
- │ ▝▜████▛▘ stack create → submit → restack → land │
51
- │ ▜██▛ /help list all commands │
52
- │ ██ │
53
- │ ▝▘ What's new in v0.3.13 │
54
- │ ● msk… · maelstrom • MIT license + public source (mergestorm-cli) │
55
- │ [████░░░░░░░░░░] 12% used │
47
+ ╭──────────────────────────────────────────────────────────────────────────────╮
48
+ │ ▟██▛ mergestorm v0.3.15 │
49
+ │ ▜▙ ● msk_live_… · maelstrom │
50
+ │ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
51
+ │ │
52
+ │ review a diff · usage for tabs · /help for all commands │
53
+ │ New in v0.3.15: PR wait + settings + both skills in the npm tarball │
56
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
57
55
  mergestorm
58
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -60,7 +58,7 @@ mergestorm
60
58
  ╰──────────────────────────────────────────────────────────────────────────────╯
61
59
  /review Review git diff (default origin/HEAD or main or master)
62
60
 
63
- mergestorm> credits
61
+ mergestorm> usage
64
62
  mergestorm> branches
65
63
  mergestorm> chain
66
64
  mergestorm> exit
@@ -68,7 +66,7 @@ mergestorm> exit
68
66
 
69
67
  Bare command names still work exactly as before (`review`, `help`, `exit`, …) — typing `/` at the start of a line opens a compact autocomplete overlay (aliases folded onto the primary name, Up/Down scroll a viewport, Esc dismisses, Tab completes, Enter accepts a prefix or submits an exact name as typed, Left/Right move the cursor). Arrow-key history, Ctrl+A/E, Ctrl+U/K work as in a regular shell. At the idle prompt, **Ctrl+C twice** (within ~1.5s) exits the shell; a single Ctrl+C clears the line and shows a confirm hint. While `review` / `status` is waiting, Ctrl+C detaches or aborts that wait (job may keep running) — it does not exit the shell. Ctrl+D on an empty line, or `exit` / `quit`, also leave the shell. On a dumb terminal, without color, or when piped, the box falls back to plain ASCII borders (or no shell at all for non-TTY input).
70
68
 
71
- `credits` / `usage` print one static panel: key · plan, a credit bar sized to the terminal, `Resets … (UTC)`, then the last five jobs. `--json` adds `recent_jobs`. `branches` (alias `chains`) lists recently reviewed branches; on a TTY you arrow-select one to open its review-chain timeline. `chain [slug]` shows that timeline directly (defaults to `local/<current-git-branch>`).
69
+ `credits` / `usage` on a TTY open a tabbed **Status / Usage / Jobs** browser (Usage selected): Left/Right switch tabs, Up/Down scroll or select, Enter on a job opens its `status` detail in place, Esc backs out, `q` closes. `status` with no job id opens the same browser on the Status tab. Piped / non-TTY output keeps the static panel: key · plan, a credit bar sized to the terminal, `Resets … (UTC)`, then the last five jobs; `--json` adds `recent_jobs` and is unchanged. `branches` (alias `chains`) lists recently reviewed branches; on a TTY you arrow-select one to open its review-chain timeline. `chain [slug]` shows that timeline directly (defaults to `local/<current-git-branch>`).
72
70
 
73
71
  ## Commands
74
72
 
@@ -82,11 +80,13 @@ mergestorm login --key Paste an existing API key instead (headless/CI)
82
80
  mergestorm logout Remove the stored API key
83
81
  mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)
84
82
  mergestorm status <job_id> Fetch a review job (envelope; --json --wait --timeout)
85
- mergestorm credits [--json] Usage panel (bar + last 5 jobs)
83
+ mergestorm status Tabbed Status / Usage / Jobs browser (TTY)
84
+ mergestorm credits [--json] Usage panel (bar + last 5 jobs); tabbed browser on a TTY
86
85
  mergestorm jobs [n] [--json] Recent review jobs (default 10, max 50)
87
86
  mergestorm branches [n] Recently reviewed branches (arrow-pick on TTY)
88
87
  mergestorm chain [slug] Branch review-chain timeline (default: current branch)
89
88
  mergestorm whoami [--json] Key prefix, plan, API base, config path
89
+ mergestorm skill install --claude|--cursor Copy the mergestorm-review and mergestorm-pr-loop skills into this repo
90
90
  mergestorm thread <slug> Jobs in a review thread
91
91
  mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk` / `--extend`)
92
92
  mergestorm stack submit Push layers, open PRs (`gh`), register via adopt (optional `--extend`)
@@ -94,11 +94,10 @@ mergestorm stack list [--json] List registered stacks
94
94
  mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
95
95
  mergestorm stack restack <stack-id> Restack descendants
96
96
  mergestorm stack land <stack-id> Land / promote (into review unit when present)
97
- mergestorm stack auto-promote on|off <stack-id> Toggle auto-promote when green
98
97
  mergestorm stack reset --force Clear local authoring state (not branches/PRs)
99
98
  ```
100
99
 
101
- `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. On review-unit stacks, `stack land` **promotes** the tip into the unit (same gates as the dashboard); otherwise it lands the bottom open PR. `stack auto-promote` turns on land-when-green for a registered stack (`auto-land` remains a deprecated alias). `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`).
100
+ `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. On review-unit stacks, `stack land` **promotes** the tip into the unit (same gates as the dashboard); otherwise it lands the bottom open PR. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`). Unattended landing is `mg queue add`.
102
101
 
103
102
  `stack create` checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and records `{ branch, parentBranch }` in CLI-managed state under **`~/.mergestorm/stacks/<repo-id>/stack.json`**. The CLI updates this automatically; never edit it. Linked worktrees share state, while independent clones remain isolated. Creating onto trunk with a non-empty active stack starts a new one. `--trunk` only sets trunk metadata — it does **not** change the parent; use `--onto main` (etc.) for a fresh stack from trunk.
104
103
 
package/dist/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type Config } from "./config.js";
2
- import type { StackDto } from "./stack-dto.js";
3
- export type { StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus, StackTempestStatus, StackUnitDto, StackUnitMemberDto, } from "./stack-dto.js";
2
+ import type { MergeQueueEntryDto, StackDto } from "./stack-dto.js";
3
+ export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus, StackTempestStatus, StackUnitDto, StackUnitMemberDto, } from "./stack-dto.js";
4
4
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
5
5
  export declare const DEFAULT_API_TIMEOUT_MS = 30000;
6
6
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
@@ -94,19 +94,92 @@ export type ThreadDetail = {
94
94
  finished_at?: string | null;
95
95
  }[];
96
96
  };
97
+ export type PrReviewFinding = {
98
+ path?: string;
99
+ line?: number;
100
+ severity: string;
101
+ body: string;
102
+ title?: string;
103
+ specialist?: string;
104
+ };
105
+ export type PrReviewFindings = {
106
+ specialists_run?: string[];
107
+ inline?: PrReviewFinding[];
108
+ off_diff?: PrReviewFinding[];
109
+ offDiff?: PrReviewFinding[];
110
+ patchPolicy?: unknown;
111
+ };
112
+ export type PrVortexReview = {
113
+ schema: "mergestorm.pr_review/v1";
114
+ id: string;
115
+ owner: string;
116
+ repo: string;
117
+ pr_number: number;
118
+ status: string;
119
+ raw_status?: string;
120
+ phase: string | null;
121
+ verdict: string | null;
122
+ summary?: string | null;
123
+ head_sha: string | null;
124
+ review_count: number;
125
+ skip_reason: string | null;
126
+ reviewed_at: string | null;
127
+ finding_count: number;
128
+ findings: PrReviewFindings | null;
129
+ patch_policy: unknown | null;
130
+ };
131
+ /**
132
+ * Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
133
+ * The connected flags are read-only there and deliberately absent here.
134
+ */
135
+ export declare const SETTINGS_WRITABLE_KEYS: readonly ["auto_review_enabled", "auto_patch_enabled", "vortex_show_thinking_traces", "repo_overview_enabled", "review_unit_land_prs_enabled", "cyclone_review_unit_land_prs_enabled", "vortex_seam_specialist_enabled"];
136
+ export type SettingsWritableKey = (typeof SETTINGS_WRITABLE_KEYS)[number];
137
+ export type SettingsPatch = Partial<Record<SettingsWritableKey, boolean>>;
138
+ /** GET and PATCH `/api/v1/settings` both answer with this shape. */
139
+ export type SettingsResponse = Record<SettingsWritableKey, boolean> & {
140
+ cyclone_connected: boolean;
141
+ github_connected: boolean;
142
+ };
143
+ /**
144
+ * Fetch Bearer /api/v1/settings; null on 404/network/timeouts so panels can
145
+ * degrade (same contract as {@link getMe}), while 401 still surfaces.
146
+ */
147
+ export declare function getSettings(cfg?: Config, opts?: {
148
+ timeoutMs?: number;
149
+ }): Promise<SettingsResponse | null>;
150
+ /**
151
+ * PATCH Bearer /api/v1/settings and return the stored result. Rejections
152
+ * (unknown key, cyclone_not_connected, …) surface the API's own `message`
153
+ * so the CLI never invents a reason.
154
+ */
155
+ export declare function patchSettings(patch: SettingsPatch, cfg?: Config, opts?: {
156
+ timeoutMs?: number;
157
+ }): Promise<SettingsResponse>;
97
158
  /** Fetch /api/v1/me; returns null on 404/network so callers can degrade. */
98
159
  export declare function getMe(cfg?: Config, opts?: {
99
160
  timeoutMs?: number;
100
161
  }): Promise<MeResponse | null>;
101
- export declare function listJobs(limit?: number, cfg?: Config): Promise<JobListItem[]>;
162
+ export declare function listJobs(limit?: number, cfg?: Config, opts?: {
163
+ timeoutMs?: number;
164
+ }): Promise<JobListItem[]>;
102
165
  /** Fetch one review job (Bearer GET /api/v1/reviews/:id). */
103
166
  export declare function getReview(jobId: string, cfg?: Config, opts?: {
104
167
  signal?: AbortSignal;
168
+ timeoutMs?: number;
105
169
  }): Promise<unknown>;
170
+ /** Fetch the latest Vortex pass for one GitHub PR. */
171
+ export declare function getPrVortexReview(owner: string, repo: string, prNumber: number, cfg?: Config, opts?: {
172
+ signal?: AbortSignal;
173
+ timeoutMs?: number;
174
+ }): Promise<PrVortexReview>;
106
175
  /** List stacks (Bearer GET /api/v1/stacks). */
107
176
  export declare function listStacks(cfg?: Config, opts?: {
108
177
  timeoutMs?: number;
109
178
  }): Promise<StackDto[]>;
179
+ /** Fetch one owned stack with enriched GitHub checks and agent state. */
180
+ export declare function getEnrichedStack(stackId: string, cfg?: Config, opts?: {
181
+ timeoutMs?: number;
182
+ }): Promise<StackDto | null>;
110
183
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
111
184
  export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config): Promise<unknown>;
112
185
  /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
@@ -120,7 +193,11 @@ export declare function restackStack(stackId: string, cfg?: Config): Promise<unk
120
193
  export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
121
194
  prNumber?: number;
122
195
  }): Promise<unknown>;
123
- /** Toggle auto-promote when green (Bearer PATCH /api/v1/stacks/:id). */
124
- export declare function setStackAutoPromote(stackId: string, autoPromoteWhenGreen: boolean, cfg?: Config): Promise<unknown>;
196
+ /** List the authenticated user's live merge-queue entries. */
197
+ export declare function listMergeQueueEntries(cfg?: Config): Promise<MergeQueueEntryDto[]>;
198
+ /** Enqueue a stack for verified landing. */
199
+ export declare function enqueueStack(stackId: string, cfg?: Config): Promise<unknown>;
200
+ /** Cancel a live merge-queue entry by entry id. */
201
+ export declare function cancelMergeQueueEntry(entryId: string, cfg?: Config): Promise<unknown>;
125
202
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
126
203
  export declare function listThreads(limit?: number, cfg?: Config): Promise<ThreadListItem[]>;
package/dist/api.js CHANGED
@@ -154,6 +154,71 @@ export async function devicePost(base, route, json, opts = {}) {
154
154
  }
155
155
  return { status: res.status, body: parsed };
156
156
  }
157
+ /**
158
+ * Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
159
+ * The connected flags are read-only there and deliberately absent here.
160
+ */
161
+ export const SETTINGS_WRITABLE_KEYS = [
162
+ "auto_review_enabled",
163
+ "auto_patch_enabled",
164
+ "vortex_show_thinking_traces",
165
+ "repo_overview_enabled",
166
+ "review_unit_land_prs_enabled",
167
+ "cyclone_review_unit_land_prs_enabled",
168
+ "vortex_seam_specialist_enabled",
169
+ ];
170
+ /**
171
+ * Fetch Bearer /api/v1/settings; null on 404/network/timeouts so panels can
172
+ * degrade (same contract as {@link getMe}), while 401 still surfaces.
173
+ */
174
+ export async function getSettings(cfg, opts) {
175
+ const resolved = cfg ?? (await loadConfig());
176
+ try {
177
+ const { status, body } = await apiFetch(resolved, "/api/v1/settings", {
178
+ timeoutMs: opts?.timeoutMs,
179
+ });
180
+ if (status !== 200)
181
+ return null;
182
+ return body;
183
+ }
184
+ catch (err) {
185
+ if (err instanceof CommandError) {
186
+ if (err.code === "api_timeout")
187
+ return null;
188
+ throw err;
189
+ }
190
+ return null;
191
+ }
192
+ }
193
+ /**
194
+ * PATCH Bearer /api/v1/settings and return the stored result. Rejections
195
+ * (unknown key, cyclone_not_connected, …) surface the API's own `message`
196
+ * so the CLI never invents a reason.
197
+ */
198
+ export async function patchSettings(patch, cfg, opts) {
199
+ const resolved = cfg ?? (await loadConfig());
200
+ if (Object.keys(patch).length === 0) {
201
+ throw new CommandError("No settings to update.", 2, "usage");
202
+ }
203
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, "/api/v1/settings", {
204
+ method: "PATCH",
205
+ json: patch,
206
+ timeoutMs: opts?.timeoutMs,
207
+ });
208
+ if (status === 404) {
209
+ throw new CommandError("Settings API is not available on this server yet. Deploy the API update or use the dashboard.");
210
+ }
211
+ if (status === 429) {
212
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
213
+ }
214
+ if (status !== 200) {
215
+ const message = body?.message;
216
+ throw new CommandError(typeof message === "string" && message.trim()
217
+ ? message
218
+ : `Failed to update settings (HTTP ${status}): ${JSON.stringify(body)}`);
219
+ }
220
+ return body;
221
+ }
157
222
  /** Fetch /api/v1/me; returns null on 404/network so callers can degrade. */
158
223
  export async function getMe(cfg, opts) {
159
224
  const resolved = cfg ?? (await loadConfig());
@@ -178,10 +243,12 @@ export async function getMe(cfg, opts) {
178
243
  return null;
179
244
  }
180
245
  }
181
- export async function listJobs(limit = 10, cfg) {
246
+ export async function listJobs(limit = 10, cfg, opts) {
182
247
  const resolved = cfg ?? (await loadConfig());
183
248
  const capped = Math.min(Math.max(limit, 1), 50);
184
- const { status, body } = await apiFetch(resolved, `/api/v1/reviews?limit=${capped}`);
249
+ const { status, body } = await apiFetch(resolved, `/api/v1/reviews?limit=${capped}`, {
250
+ timeoutMs: opts?.timeoutMs,
251
+ });
185
252
  if (status === 404) {
186
253
  throw new CommandError("Job list is not available on this server yet. Update the API or use the dashboard.");
187
254
  }
@@ -198,7 +265,7 @@ export async function getReview(jobId, cfg, opts) {
198
265
  if (!id) {
199
266
  throw new CommandError("job_id is required", 2, "usage");
200
267
  }
201
- const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/reviews/${encodeURIComponent(id)}`, { signal: opts?.signal });
268
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/reviews/${encodeURIComponent(id)}`, { signal: opts?.signal, timeoutMs: opts?.timeoutMs });
202
269
  if (status === 404) {
203
270
  throw new CommandError(`Review not found: ${id}`);
204
271
  }
@@ -210,21 +277,72 @@ export async function getReview(jobId, cfg, opts) {
210
277
  }
211
278
  return body;
212
279
  }
280
+ /** Fetch the latest Vortex pass for one GitHub PR. */
281
+ export async function getPrVortexReview(owner, repo, prNumber, cfg, opts) {
282
+ const resolved = cfg ?? (await loadConfig());
283
+ const cleanOwner = owner.trim();
284
+ const cleanRepo = repo.trim();
285
+ if (!cleanOwner || !cleanRepo || !Number.isInteger(prNumber) || prNumber <= 0) {
286
+ throw new CommandError("owner, repo, and a positive pr_number are required", 2, "usage");
287
+ }
288
+ const query = new URLSearchParams({
289
+ owner: cleanOwner,
290
+ repo: cleanRepo,
291
+ pr_number: String(prNumber),
292
+ });
293
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/stacks/pr-review?${query.toString()}`, { signal: opts?.signal, timeoutMs: opts?.timeoutMs });
294
+ if (status === 404) {
295
+ throw new CommandError(`Vortex review not found: ${cleanOwner}/${cleanRepo}#${prNumber}`, REVIEW_EXIT.failed, "not_found");
296
+ }
297
+ if (status === 429) {
298
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
299
+ }
300
+ if (status !== 200) {
301
+ throw new CommandError(`Failed to get PR review (HTTP ${status}): ${JSON.stringify(body)}`, REVIEW_EXIT.failed, "review_failed");
302
+ }
303
+ return body;
304
+ }
213
305
  /** List stacks (Bearer GET /api/v1/stacks). */
214
306
  export async function listStacks(cfg, opts) {
215
307
  const resolved = cfg ?? (await loadConfig());
216
- const { status, body } = await apiFetch(resolved, "/api/v1/stacks", {
308
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, "/api/v1/stacks", {
217
309
  timeoutMs: opts?.timeoutMs,
218
310
  });
219
311
  if (status === 404) {
220
312
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
221
313
  }
314
+ if (status === 429) {
315
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
316
+ }
222
317
  if (status !== 200) {
223
318
  throw new CommandError(`Failed to list stacks (HTTP ${status}): ${JSON.stringify(body)}`);
224
319
  }
225
320
  const stacks = body.stacks;
226
321
  return Array.isArray(stacks) ? stacks : [];
227
322
  }
323
+ /** Fetch one owned stack with enriched GitHub checks and agent state. */
324
+ export async function getEnrichedStack(stackId, cfg, opts) {
325
+ const resolved = cfg ?? (await loadConfig());
326
+ const id = stackId.trim();
327
+ if (!id) {
328
+ throw new CommandError("stack_id is required", 2, "usage");
329
+ }
330
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, { timeoutMs: opts?.timeoutMs });
331
+ if (status === 404) {
332
+ throw new CommandError("Stack enrichment is not available on this server yet. Deploy the API update or use the dashboard.");
333
+ }
334
+ if (status === 429) {
335
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
336
+ }
337
+ if (status !== 200) {
338
+ throw new CommandError(`Failed to get stack status (HTTP ${status}): ${JSON.stringify(body)}`);
339
+ }
340
+ const stacks = body.stacks;
341
+ if (!Array.isArray(stacks)) {
342
+ throw new CommandError(`Failed to get stack status (HTTP 200): ${JSON.stringify(body)}`);
343
+ }
344
+ return stacks.find((stack) => stack?.id === id) ?? null;
345
+ }
228
346
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
229
347
  export async function adoptStack(owner, repo, prNumber, cfg) {
230
348
  const resolved = cfg ?? (await loadConfig());
@@ -283,9 +401,50 @@ export async function landNextStack(stackId, cfg, opts) {
283
401
  : {};
284
402
  return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
285
403
  }
286
- /** Toggle auto-promote when green (Bearer PATCH /api/v1/stacks/:id). */
287
- export async function setStackAutoPromote(stackId, autoPromoteWhenGreen, cfg) {
288
- return stackMutation(stackId, "", "PATCH", { autoPromoteWhenGreen }, "Failed to update auto-promote", cfg);
404
+ /** List the authenticated user's live merge-queue entries. */
405
+ export async function listMergeQueueEntries(cfg) {
406
+ const resolved = cfg ?? (await loadConfig());
407
+ const { status, body } = await apiFetch(resolved, "/api/v1/stacks/queue");
408
+ if (status === 404) {
409
+ throw new CommandError("Merge queue API is not available on this server yet. Deploy the API update or use the dashboard.");
410
+ }
411
+ if (status !== 200) {
412
+ throw new CommandError(`Failed to list merge queue (HTTP ${status}): ${JSON.stringify(body)}`);
413
+ }
414
+ const entries = body.entries;
415
+ if (!Array.isArray(entries)) {
416
+ throw new CommandError(`Failed to list merge queue (HTTP 200): ${JSON.stringify(body)}`);
417
+ }
418
+ return entries;
419
+ }
420
+ async function queueMutation(route, failLabel, cfg) {
421
+ const resolved = cfg ?? (await loadConfig());
422
+ const { status, body } = await apiFetch(resolved, route, {
423
+ method: "POST",
424
+ json: {},
425
+ });
426
+ if (status === 404) {
427
+ const error = body?.error;
428
+ if (error === "stack_not_found") {
429
+ throw new CommandError("Stack not found");
430
+ }
431
+ if (error === "entry_not_found") {
432
+ throw new CommandError("Merge queue entry not found");
433
+ }
434
+ throw new CommandError("Merge queue API is not available on this server yet. Deploy the API update or use the dashboard.");
435
+ }
436
+ if (status < 200 || status >= 300) {
437
+ throw new CommandError(`${failLabel} (HTTP ${status}): ${JSON.stringify(body)}`);
438
+ }
439
+ return body;
440
+ }
441
+ /** Enqueue a stack for verified landing. */
442
+ export async function enqueueStack(stackId, cfg) {
443
+ return queueMutation(`/api/v1/stacks/${encodeURIComponent(stackId)}/enqueue`, "Failed to add stack to merge queue", cfg);
444
+ }
445
+ /** Cancel a live merge-queue entry by entry id. */
446
+ export async function cancelMergeQueueEntry(entryId, cfg) {
447
+ return queueMutation(`/api/v1/stacks/queue/${encodeURIComponent(entryId)}/cancel`, "Failed to remove merge queue entry", cfg);
289
448
  }
290
449
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
291
450
  export async function listThreads(limit = 20, cfg) {
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, type Config, } from "../config.js";
2
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, adoptStack, apiFetch, devicePost, ensureUpperPark, getMe, getReview, landNextStack, listJobs, listStacks, listThreads, parseRetryAfterSeconds, restackStack, setStackAutoPromote, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type ThreadDetail, type ThreadListItem, } from "../api.js";
2
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type SettingsResponse, type SettingsWritableKey, type StackDto, type ThreadDetail, type ThreadListItem, } from "../api.js";
3
3
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, type CommandErrorCode, type CommandErrorExtras, } from "../errors.js";
4
- export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
4
+ export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
5
5
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig, mergestormHome, resolveApiKey, saveConfig, } from "../config.js";
2
- export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, adoptStack, apiFetch, devicePost, ensureUpperPark, getMe, getReview, landNextStack, listJobs, listStacks, listThreads, parseRetryAfterSeconds, restackStack, setStackAutoPromote, } from "../api.js";
2
+ export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, } from "../api.js";
3
3
  export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
4
- export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
4
+ export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
5
5
  export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
@@ -2,9 +2,13 @@ import { type ThreadListItem } from "../api.js";
2
2
  /** One-line label for a branch/thread row (also used by tests). */
3
3
  export declare function formatBranchRow(item: ThreadListItem): string;
4
4
  /** Interactive list of recently reviewed branches → open chain on select. */
5
- export declare function cmdBranches(args?: string[]): Promise<void>;
5
+ export declare function cmdBranches(args?: string[], opts?: {
6
+ mode?: "oneshot" | "shell";
7
+ }): Promise<void>;
6
8
  /**
7
9
  * Show the review-chain timeline for a branch/thread.
8
10
  * Defaults to `local/<current-git-branch>` when no slug is given.
9
11
  */
10
- export declare function cmdChain(args?: string[]): Promise<void>;
12
+ export declare function cmdChain(args?: string[], opts?: {
13
+ mode?: "oneshot" | "shell";
14
+ }): Promise<void>;
@@ -4,8 +4,10 @@ import { loadConfig } from "../config.js";
4
4
  import { CommandError } from "../errors.js";
5
5
  import { git } from "../git.js";
6
6
  import { ansi } from "../ui/ansi.js";
7
+ import { present } from "../ui/present.js";
7
8
  import { selectFromList } from "../ui/select.js";
8
- import { cmdThread, relativeWhen } from "./jobs.js";
9
+ import { buildChainHelpLines, cmdThread, relativeWhen } from "./jobs.js";
10
+ import { showLinePanel } from "../ui/line-tabs.js";
9
11
  /** One-line label for a branch/thread row (also used by tests). */
10
12
  export function formatBranchRow(item) {
11
13
  const name = (item.branch?.trim() || item.slug).slice(0, 36);
@@ -18,7 +20,7 @@ export function formatBranchRow(item) {
18
20
  const closed = item.status === "closed" ? " closed" : "";
19
21
  return `${name.padEnd(36)} ${jobs.padEnd(8)} ${verdict.padEnd(12)} ${when}${pr}${closed}`;
20
22
  }
21
- function printBranchTable(items) {
23
+ function formatBranchTableLines(items) {
22
24
  const header = ["BRANCH / SLUG", "JOBS", "VERDICT", "WHEN", "PR"];
23
25
  const rows = items.map((t) => [
24
26
  (t.branch?.trim() || t.slug).slice(0, 36),
@@ -29,28 +31,38 @@ function printBranchTable(items) {
29
31
  ]);
30
32
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]?.length ?? 0)));
31
33
  const fmt = (cols) => " " + cols.map((c, i) => c.padEnd(widths[i])).join(" ");
32
- console.log(ansi.dim(fmt(header)));
33
- for (const r of rows)
34
- console.log(fmt(r));
35
- console.log(ansi.dim(" open a chain: chain <slug> · or re-run on a TTY to pick"));
34
+ return [
35
+ ansi.dim(fmt(header)),
36
+ ...rows.map((r) => fmt(r)),
37
+ ansi.dim(" open a chain: chain <slug> · or re-run on a TTY to pick"),
38
+ ];
36
39
  }
37
40
  /** Interactive list of recently reviewed branches → open chain on select. */
38
- export async function cmdBranches(args = []) {
41
+ export async function cmdBranches(args = [], opts = {}) {
39
42
  const asJson = args.includes("--json");
40
43
  const nArg = args.find((a) => /^\d+$/.test(a));
41
44
  const limit = nArg ? Number.parseInt(nArg, 10) : 20;
42
45
  const cfg = await loadConfig();
43
46
  const items = await listThreads(limit, cfg);
44
47
  if (asJson) {
45
- console.log(JSON.stringify({ items }, null, 2));
48
+ const text = JSON.stringify({ items }, null, 2);
49
+ if (opts.mode === "shell") {
50
+ await present("Branches", text.split("\n"));
51
+ return;
52
+ }
53
+ console.log(text);
46
54
  return;
47
55
  }
48
56
  if (items.length === 0) {
49
- console.log(ansi.dim(" No reviewed branches yet. Run `review` on a branch first."));
57
+ await present("Branches", [
58
+ ` ${ansi.bold("No reviewed branches yet")}`,
59
+ "",
60
+ ` ${ansi.brightGreen("review")} submit a diff on this branch`,
61
+ ]);
50
62
  return;
51
63
  }
52
64
  if (!input.isTTY || !output.isTTY) {
53
- printBranchTable(items);
65
+ await present("Branches", formatBranchTableLines(items));
54
66
  return;
55
67
  }
56
68
  const picked = await selectFromList({
@@ -59,7 +71,6 @@ export async function cmdBranches(args = []) {
59
71
  render: (item) => formatBranchRow(item),
60
72
  });
61
73
  if (!picked) {
62
- console.log(ansi.dim(" cancelled"));
63
74
  return;
64
75
  }
65
76
  await cmdThread(picked.slug, []);
@@ -68,7 +79,7 @@ export async function cmdBranches(args = []) {
68
79
  * Show the review-chain timeline for a branch/thread.
69
80
  * Defaults to `local/<current-git-branch>` when no slug is given.
70
81
  */
71
- export async function cmdChain(args = []) {
82
+ export async function cmdChain(args = [], opts = {}) {
72
83
  const asJson = args.includes("--json");
73
84
  const positional = args.filter((a) => a !== "--json");
74
85
  let slug = positional[0]?.trim() ?? "";
@@ -78,8 +89,12 @@ export async function cmdChain(args = []) {
78
89
  slug = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
79
90
  }
80
91
  catch {
81
- throw new CommandError("usage: chain <slug> (or run inside a git repo to use the current branch)");
92
+ if (asJson || !process.stdin.isTTY || !process.stdout.isTTY) {
93
+ throw new CommandError("usage: chain <slug> (or run inside a git repo to use the current branch)");
94
+ }
95
+ await showLinePanel("Chain", buildChainHelpLines({ reason: "no-repo" }));
96
+ return;
82
97
  }
83
98
  }
84
- await cmdThread(slug, asJson ? ["--json"] : []);
99
+ await cmdThread(slug, asJson ? ["--json"] : [], opts);
85
100
  }
@@ -0,0 +1,39 @@
1
+ import { type JobListItem, type SettingsResponse, type SettingsWritableKey } from "../api.js";
2
+ import { type ReviewJobRow } from "../ui/job-envelope.js";
3
+ import { type TabId, type TabsConfigRow } from "../ui/tabs.js";
4
+ import { type UsagePanelJob } from "../ui/usage.js";
5
+ /** The tabbed browser needs a real terminal on both ends. */
6
+ export declare function canBrowse(): boolean;
7
+ /** Shared job row mapper for the Usage tab and the static `credits` panel. */
8
+ export declare function toPanelJob(job: JobListItem): UsagePanelJob;
9
+ export type StatusTabInput = {
10
+ keyPrefix: string;
11
+ keyName: string | null;
12
+ plan: string | null;
13
+ apiBase: string;
14
+ reachable: boolean;
15
+ /** getMe surfaced a 401: the key is invalid/revoked, not the API down. */
16
+ keyInvalid: boolean;
17
+ used?: number;
18
+ limit?: number | null;
19
+ remaining?: number | null;
20
+ resetsAt?: string | null;
21
+ now?: Date;
22
+ };
23
+ /** Short chrome note — lives on the hint row, not in the Status body. */
24
+ export declare function statusReachabilityNote(input: Pick<StatusTabInput, "reachable" | "keyInvalid">): string;
25
+ /** Status tab body: whoami + credits from `/me`. Reachability is the footer. */
26
+ export declare function buildStatusTabLines(input: StatusTabInput): string[];
27
+ /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
28
+ export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
+ /** Human labels for the Config tab and the static `settings` printout. */
30
+ export declare const SETTINGS_LABELS: Record<SettingsWritableKey, string>;
31
+ /** Config rows in a stable order: writable toggles, then connected flags. */
32
+ export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
33
+ /**
34
+ * Data + entry for the tabbed Status / Usage / Jobs / Config browser (F-9).
35
+ * Degrades honestly: a null `/me` marks the API unreachable and the Usage
36
+ * tab says so, and a failed settings GET leaves the Config tab reporting
37
+ * itself unavailable instead of drawing toggles we do not have.
38
+ */
39
+ export declare function openTabsBrowser(initial: TabId): Promise<void>;