mergestorm 0.3.8 → 0.3.13

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 (44) hide show
  1. package/README.md +59 -14
  2. package/dist/api.d.ts +27 -38
  3. package/dist/api.js +81 -4
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/credits.js +33 -12
  7. package/dist/commands/jobs.js +1 -1
  8. package/dist/commands/login.js +29 -8
  9. package/dist/commands/registry.d.ts +1 -1
  10. package/dist/commands/registry.js +26 -19
  11. package/dist/commands/review-client.d.ts +93 -0
  12. package/dist/commands/review-client.js +289 -0
  13. package/dist/commands/review.d.ts +30 -6
  14. package/dist/commands/review.js +385 -146
  15. package/dist/commands/stack.d.ts +34 -2
  16. package/dist/commands/stack.js +256 -62
  17. package/dist/commands/status.d.ts +16 -2
  18. package/dist/commands/status.js +221 -21
  19. package/dist/errors.d.ts +18 -2
  20. package/dist/errors.js +22 -1
  21. package/dist/git-stack.d.ts +2 -0
  22. package/dist/git-stack.js +4 -0
  23. package/dist/git.d.ts +12 -1
  24. package/dist/git.js +52 -7
  25. package/dist/shell.d.ts +1 -1
  26. package/dist/shell.js +7 -4
  27. package/dist/stack-dto.d.ts +185 -0
  28. package/dist/stack-dto.js +7 -0
  29. package/dist/submit-pr-base.d.ts +31 -0
  30. package/dist/submit-pr-base.js +69 -0
  31. package/dist/ui/banner.js +12 -0
  32. package/dist/ui/box.d.ts +13 -9
  33. package/dist/ui/box.js +28 -44
  34. package/dist/ui/findings.d.ts +2 -0
  35. package/dist/ui/findings.js +2 -1
  36. package/dist/ui/job-envelope.d.ts +68 -0
  37. package/dist/ui/job-envelope.js +35 -0
  38. package/dist/ui/prompt.d.ts +91 -0
  39. package/dist/ui/prompt.js +241 -62
  40. package/dist/ui/usage.d.ts +25 -1
  41. package/dist/ui/usage.js +50 -1
  42. package/dist/ui/width.d.ts +26 -0
  43. package/dist/ui/width.js +262 -0
  44. package/package.json +6 -2
package/README.md CHANGED
@@ -28,7 +28,7 @@ After install, both `mergestorm` and `mg` invoke the same CLI.
28
28
  ## Source
29
29
 
30
30
  Public source (MIT): [github.com/marginsystems/mergestorm-cli](https://github.com/marginsystems/mergestorm-cli).
31
- Tags there match npm versions (`v0.3.8`, …).
31
+ Tags there match npm versions (`v0.3.13`, …). Clone, install, and build:
32
32
 
33
33
  ```bash
34
34
  git clone https://github.com/marginsystems/mergestorm-cli.git
@@ -36,6 +36,7 @@ cd mergestorm-cli
36
36
  npm install
37
37
  npm run build
38
38
  node dist/cli.js --help
39
+ # optional: npm link # puts `mergestorm` / `mg` on your PATH
39
40
  ```
40
41
 
41
42
  ## Interactive shell
@@ -43,13 +44,13 @@ node dist/cli.js --help
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
45
 
45
46
  ```
46
- ╭─ mergestorm v0.3.8 ──────────────────────────────────────────────────────────╮
47
+ ╭─ mergestorm v0.3.13 ─────────────────────────────────────────────────────────╮
47
48
  │ ▟██████████▛ Tips for getting started │
48
49
  │ ▜████████▛ review review origin/HEAD or main │
49
50
  │ ▝▜████▛▘ stack create → submit → restack → land │
50
51
  │ ▜██▛ /help list all commands │
51
52
  │ ██ │
52
- │ ▝▘ What's new in v0.3.8 │
53
+ │ ▝▘ What's new in v0.3.13 │
53
54
  │ ● msk… · maelstrom • MIT license + public source (mergestorm-cli) │
54
55
  │ [████░░░░░░░░░░] 12% used │
55
56
  ╰──────────────────────────────────────────────────────────────────────────────╯
@@ -65,9 +66,9 @@ mergestorm> chain
65
66
  mergestorm> exit
66
67
  ```
67
68
 
68
- Bare command names still work exactly as before (`review`, `help`, `exit`, …) — typing `/` at the start of a line just opens the live autocomplete dropdown (filtered as you type, navigate with Up/Down, accept with Tab/Enter/Right). 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).
69
+ 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).
69
70
 
70
- `credits` / `usage` print a usage meter — long bar + `N% used`, then `Resets … (UTC)` — for standard and premium. `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>`).
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>`).
71
72
 
72
73
  ## Commands
73
74
 
@@ -80,15 +81,15 @@ mergestorm login Sign in via browser; stores an API key in ~/.mer
80
81
  mergestorm login --key Paste an existing API key instead (headless/CI)
81
82
  mergestorm logout Remove the stored API key
82
83
  mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)
83
- mergestorm status <job_id> Fetch a review job and print JSON (one shot)
84
- mergestorm credits [--json] Standard + premium credit balance (usage bars)
84
+ mergestorm status <job_id> Fetch a review job (envelope; --json --wait --timeout)
85
+ mergestorm credits [--json] Usage panel (bar + last 5 jobs)
85
86
  mergestorm jobs [n] [--json] Recent review jobs (default 10, max 50)
86
87
  mergestorm branches [n] Recently reviewed branches (arrow-pick on TTY)
87
88
  mergestorm chain [slug] Branch review-chain timeline (default: current branch)
88
89
  mergestorm whoami [--json] Key prefix, plan, API base, config path
89
90
  mergestorm thread <slug> Jobs in a review thread
90
- mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk`)
91
- mergestorm stack submit Push layers, open chained PRs (`gh`), register via adopt
91
+ mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk` / `--extend`)
92
+ mergestorm stack submit Push layers, open PRs (`gh`), register via adopt (optional `--extend`)
92
93
  mergestorm stack list [--json] List registered stacks
93
94
  mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
94
95
  mergestorm stack restack <stack-id> Restack descendants
@@ -99,7 +100,9 @@ mergestorm stack reset --force Clear local authoring state (not branches/PRs)
99
100
 
100
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`).
101
102
 
102
- `stack create` is local-only: it 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. No repository file, GitHub call, or API call is made.
103
+ `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
+
105
+ If the parent branch is already a layer of a **registered** (submitted) stack, `create` and `submit` refuse unless you pass **`--extend`**. That blocks accidentally gluing an unrelated PR onto an open unit. Growing a registered stack on purpose: `mg stack create --onto <tip> --extend` → commit → `mg stack submit --extend`.
103
106
 
104
107
  `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push/PR auth is your local `git` + `gh auth` — not a GitHub App installation token.
105
108
 
@@ -109,7 +112,7 @@ Legacy repo-local `.mergestorm/stack.json` state migrates automatically on the n
109
112
 
110
113
  Runs a browser device-authorization flow: prints a short code, opens `https://mergestorm.ai/cli/auth`, and — once you approve — mints and stores an API key in `~/.mergestorm/config.json` (mode `600`). No key copy/paste needed.
111
114
 
112
- For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [API tab](https://mergestorm.ai/dashboard/api)). On a TTY the paste is hidden (not echoed); the CLI verifies the key with the API before saving.
115
+ For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [Settings → API](https://mergestorm.ai/settings#api)). On a TTY the paste is hidden (not echoed); the CLI verifies the key with the API before saving.
113
116
 
114
117
  ### `logout`
115
118
 
@@ -117,19 +120,38 @@ Removes `~/.mergestorm/config.json` so the stored key is no longer used.
117
120
 
118
121
  ### `review [base] [head]`
119
122
 
120
- Collects `git diff base...head` (default: discovered trunk — `origin/HEAD`, `main`, or `master`), attaches the changed file contents (up to 40 files, skipping files > 400 KB), submits the job, and polls until it finishes (~3 min max). The thread slug is derived from the current branch (`local/<branch>`), so repeated runs on the same branch chain their findings. Programmatic callers can provide explicit review context through the Review Jobs API.
123
+ Collects `git diff base...head` (default: discovered trunk — `origin/HEAD`, `main`, or `master`), attaches the changed file contents (up to 40 files, skipping files > 400 KB), submits the job, and polls until it finishes (~8 min max — Core plus a Max fleet can exceed 3 minutes). The thread slug defaults to the current branch (`local/<branch>`); pass `--thread` to override so follow-ups chain on a chosen slug. The CLI does not scan `.mergestorm/context/`.
121
124
 
122
125
  ```bash
123
126
  mergestorm review # discovered trunk (origin/HEAD, main, or master)...HEAD
124
127
  mergestorm review develop # develop...HEAD
125
128
  mergestorm review main feat/x # main...feat/x
129
+ mergestorm review main --router max
130
+ mergestorm review main --router manual --specialists security,frontend
131
+ mergestorm review --json --context "focus on auth" --context-file docs/adr/007.md
126
132
  ```
127
133
 
128
- Exit code is non-zero on quota limit (402), submission failure, a `failed`/`quota_exceeded` job, or a poll timeout. In the interactive shell, Ctrl+C during the wait detaches (job keeps running) — use `status <job_id>` later.
134
+ `--router` is the same smart-router mode as Agents settings (`off` / `standard` / `max` / `manual`). `--specialists` pins or invokes lanes on that request (comma-separated). Omit both to use the signed-in user's saved fleet settings (paid default is Standard, same as the dashboard). Manual accepts up to 5 lane ids — the same cap as Agents (the smart router caps `standard` at 3 and `max` at 5).
135
+
136
+ `--context` is author guidance (or `--context -` to read stdin). `--context-file` is repeatable up to 10 files (16 KB each, 60 KB total). `--idempotency-key` replays a prior 202 for the same key. `--webhook-url` must be https; the one-time `webhook_secret` is printed only in `--json` output. Webhook delivery is best-effort (no retry).
137
+
138
+ Exit codes for `review` and `status`:
139
+
140
+ | Exit | Meaning |
141
+ |------|---------|
142
+ | 0 | Success (`completed` or `no_changes`) |
143
+ | 2 | Usage / bad flags |
144
+ | 3 | Quota (`402` or `quota_exceeded`) |
145
+ | 4 | Failed job or other submission error |
146
+ | 5 | Poll / request timeout |
147
+ | 6 | Auth (missing or invalid API key) |
148
+ | 7 | Rate limited (`429`) — wait `retry_after_seconds`, or check a running review with `mergestorm jobs` / `status` |
149
+
150
+ In the interactive shell, Ctrl+C during the wait detaches (job keeps running) — use `status <job_id>` later.
129
151
 
130
152
  ### `status <job_id>`
131
153
 
132
- Fetches a job by id and prints the raw JSON (status, verdict, summary, findings). In the shell, output is formatted for reading.
154
+ Fetches a job by id. One-shot default (and `--json` in either mode) prints the same `mergestorm.review_job/v1` object as `review --json`. `--wait [--timeout seconds]` polls until the job finishes; timeout is exit 5 with the id in the object. In the shell, default output is pretty (requested vs run specialists and credits).
133
155
 
134
156
  ### `credits` / `jobs` / `whoami`
135
157
 
@@ -147,6 +169,29 @@ Require a logged-in key and API support for `GET /api/v1/me` and `GET /api/v1/re
147
169
  | `MERGESTORM_API_URL` | API base URL | `https://api.mergestorm.ai` |
148
170
  | `NO_COLOR` | Disable ANSI colors | — |
149
171
 
172
+ ## Verify (public source vs npm)
173
+
174
+ Build **only** from [marginsystems/mergestorm-cli](https://github.com/marginsystems/mergestorm-cli). Tags there (`vX.Y.Z`) match npm versions. Compare your local pack to the registry hashes below (or `npm view mergestorm@VERSION dist`).
175
+
176
+ | npm | Public git tag | Public commit | npm `dist.shasum` (sha1) | npm `dist.integrity` |
177
+ |-----|----------------|---------------|--------------------------|----------------------|
178
+ | `0.3.9` | [`v0.3.9`](https://github.com/marginsystems/mergestorm-cli/tree/v0.3.9) | `89eaebc29cf793db52baf3e9af0f2bed6e42e458` | `f904f4f642a07c0f9716fe36ed9e5a813be47530` | `sha512-zkbEhwkog13xC70LcM77bWFzLTyoE7Q81daaxz/1Gk3IqVyEoyhKpR+PAtfTp+Mk3MqJ7yVhvZ8/9UG4hpT/Dw==` |
179
+
180
+ ```bash
181
+ git clone https://github.com/marginsystems/mergestorm-cli.git
182
+ cd mergestorm-cli
183
+ git checkout v0.3.9
184
+ git rev-parse HEAD # expect 89eaebc29cf793db52baf3e9af0f2bed6e42e458
185
+ npm ci
186
+ npm run build
187
+ npm pack
188
+ shasum -a 1 mergestorm-0.3.9.tgz
189
+ # expect f904f4f642a07c0f9716fe36ed9e5a813be47530
190
+ # also: npm view mergestorm@0.3.9 dist.shasum dist.integrity
191
+ ```
192
+
193
+ After each release, the new row’s git commit is the public `v*` tag tip; shasum/integrity come from the npm registry for that version.
194
+
150
195
  ## See also
151
196
 
152
197
  - [Public source — mergestorm-cli](https://github.com/marginsystems/mergestorm-cli)
package/dist/api.d.ts CHANGED
@@ -1,4 +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
4
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
3
5
  export declare const DEFAULT_API_TIMEOUT_MS = 30000;
4
6
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
@@ -8,10 +10,17 @@ export type ApiFetchInit = RequestInit & {
8
10
  /** Override {@link DEFAULT_API_TIMEOUT_MS}. */
9
11
  timeoutMs?: number;
10
12
  };
11
- export declare function apiFetch(cfg: Config, route: string, init?: ApiFetchInit): Promise<{
13
+ export type ApiFetchResult = {
12
14
  status: number;
13
15
  body: unknown;
14
- }>;
16
+ retryAfterSeconds?: number;
17
+ };
18
+ /** Prefer the larger of `Retry-After` and JSON `retry_after_seconds`. */
19
+ export declare function parseRetryAfterSeconds(input: {
20
+ header?: string | null;
21
+ body?: unknown;
22
+ }): number | undefined;
23
+ export declare function apiFetch(cfg: Config, route: string, init?: ApiFetchInit): Promise<ApiFetchResult>;
15
24
  export declare function devicePost(base: string, route: string, json: unknown, opts?: {
16
25
  timeoutMs?: number;
17
26
  signal?: AbortSignal;
@@ -28,7 +37,7 @@ export type MeResponse = {
28
37
  };
29
38
  plan_key: string;
30
39
  plan_label_key?: string;
31
- /** Start of next UTC month (usage meters reset). Older APIs omit this. */
40
+ /** When the current credit window ends (signup anniversary or Stripe period). Older APIs omit this. */
32
41
  resets_at?: string;
33
42
  usage: {
34
43
  standard: {
@@ -36,11 +45,6 @@ export type MeResponse = {
36
45
  limit: number | null;
37
46
  remaining: number | null;
38
47
  };
39
- premium: {
40
- used: number;
41
- limit: number | null;
42
- remaining: number | null;
43
- };
44
48
  };
45
49
  };
46
50
  export type JobListItem = {
@@ -53,7 +57,9 @@ export type JobListItem = {
53
57
  head_label: string | null;
54
58
  created_at: string;
55
59
  finished_at: string | null;
56
- credits: number;
60
+ credits: {
61
+ standard: number;
62
+ } | null;
57
63
  };
58
64
  export type ThreadListItem = {
59
65
  slug: string;
@@ -93,38 +99,21 @@ export declare function getMe(cfg?: Config, opts?: {
93
99
  timeoutMs?: number;
94
100
  }): Promise<MeResponse | null>;
95
101
  export declare function listJobs(limit?: number, cfg?: Config): Promise<JobListItem[]>;
96
- export type StackLayerDto = {
97
- branch: string;
98
- parentBranch: string | null;
99
- prNumber: number;
100
- position: number;
101
- state: string;
102
- title: string | null;
103
- htmlUrl: string | null;
104
- ciStatus: string;
105
- reviewStatus: string;
106
- checks: {
107
- total: number;
108
- success: number;
109
- pending: number;
110
- failure: number;
111
- failingName: string | null;
112
- } | null;
113
- conflictDetail: string | null;
114
- lastRestackedSha: string | null;
115
- };
116
- export type StackDto = {
117
- id: string;
118
- owner: string;
119
- repo: string;
120
- trunkBranch: string;
121
- autoPromoteWhenGreen: boolean;
122
- layers: StackLayerDto[];
123
- };
102
+ /** Fetch one review job (Bearer GET /api/v1/reviews/:id). */
103
+ export declare function getReview(jobId: string, cfg?: Config, opts?: {
104
+ signal?: AbortSignal;
105
+ }): Promise<unknown>;
124
106
  /** List stacks (Bearer GET /api/v1/stacks). */
125
- export declare function listStacks(cfg?: Config): Promise<StackDto[]>;
107
+ export declare function listStacks(cfg?: Config, opts?: {
108
+ timeoutMs?: number;
109
+ }): Promise<StackDto[]>;
126
110
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
127
111
  export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config): Promise<unknown>;
112
+ /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
113
+ export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<{
114
+ freezeBranch: string;
115
+ created: boolean;
116
+ }>;
128
117
  /** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
129
118
  export declare function restackStack(stackId: string, cfg?: Config): Promise<unknown>;
130
119
  /** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
package/dist/api.js CHANGED
@@ -1,9 +1,42 @@
1
1
  import { apiBase, loadConfig, resolveApiKey } from "./config.js";
2
- import { CommandError } from "./errors.js";
2
+ import { CommandError, REVIEW_EXIT, rateLimitedMessage } from "./errors.js";
3
3
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
4
4
  export const DEFAULT_API_TIMEOUT_MS = 30_000;
5
5
  /** Message prefix of the CommandError thrown when a request exceeds its timeout. */
6
6
  export const API_TIMEOUT_PREFIX = "Request timed out after";
7
+ function asPositiveSeconds(value) {
8
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
9
+ return value;
10
+ }
11
+ if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value.trim())) {
12
+ return Number(value.trim());
13
+ }
14
+ return undefined;
15
+ }
16
+ function parseRetryAfterHeader(header) {
17
+ if (!header)
18
+ return undefined;
19
+ const trimmed = header.trim();
20
+ const asNumber = asPositiveSeconds(trimmed);
21
+ if (asNumber !== undefined)
22
+ return asNumber;
23
+ const when = Date.parse(trimmed);
24
+ if (!Number.isFinite(when))
25
+ return undefined;
26
+ return Math.max(0, (when - Date.now()) / 1000);
27
+ }
28
+ /** Prefer the larger of `Retry-After` and JSON `retry_after_seconds`. */
29
+ export function parseRetryAfterSeconds(input) {
30
+ const fromBody = input.body && typeof input.body === "object"
31
+ ? asPositiveSeconds(input.body.retry_after_seconds)
32
+ : undefined;
33
+ const fromHeader = parseRetryAfterHeader(input.header);
34
+ if (fromBody === undefined)
35
+ return fromHeader;
36
+ if (fromHeader === undefined)
37
+ return fromBody;
38
+ return Math.max(fromBody, fromHeader);
39
+ }
7
40
  function isAbortLike(err) {
8
41
  return (err instanceof Error &&
9
42
  (err.name === "AbortError" || err.name === "TimeoutError"));
@@ -82,7 +115,15 @@ export async function apiFetch(cfg, route, init = {}) {
82
115
  if (res.status === 401) {
83
116
  throw new CommandError("API key invalid or revoked. Run `mergestorm login`.", 1, "auth_invalid");
84
117
  }
85
- return { status: res.status, body: parsed };
118
+ const retryAfterSeconds = parseRetryAfterSeconds({
119
+ header: res.headers.get("retry-after"),
120
+ body: parsed,
121
+ });
122
+ return {
123
+ status: res.status,
124
+ body: parsed,
125
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
126
+ };
86
127
  }
87
128
  export async function devicePost(base, route, json, opts = {}) {
88
129
  const timeoutMs = opts.timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
@@ -150,10 +191,31 @@ export async function listJobs(limit = 10, cfg) {
150
191
  const items = body.items;
151
192
  return Array.isArray(items) ? items : [];
152
193
  }
194
+ /** Fetch one review job (Bearer GET /api/v1/reviews/:id). */
195
+ export async function getReview(jobId, cfg, opts) {
196
+ const resolved = cfg ?? (await loadConfig());
197
+ const id = jobId.trim();
198
+ if (!id) {
199
+ throw new CommandError("job_id is required", 2, "usage");
200
+ }
201
+ const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/reviews/${encodeURIComponent(id)}`, { signal: opts?.signal });
202
+ if (status === 404) {
203
+ throw new CommandError(`Review not found: ${id}`);
204
+ }
205
+ if (status === 429) {
206
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
207
+ }
208
+ if (status !== 200) {
209
+ throw new CommandError(`Failed to get review (HTTP ${status}): ${JSON.stringify(body)}`);
210
+ }
211
+ return body;
212
+ }
153
213
  /** List stacks (Bearer GET /api/v1/stacks). */
154
- export async function listStacks(cfg) {
214
+ export async function listStacks(cfg, opts) {
155
215
  const resolved = cfg ?? (await loadConfig());
156
- const { status, body } = await apiFetch(resolved, "/api/v1/stacks");
216
+ const { status, body } = await apiFetch(resolved, "/api/v1/stacks", {
217
+ timeoutMs: opts?.timeoutMs,
218
+ });
157
219
  if (status === 404) {
158
220
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
159
221
  }
@@ -178,6 +240,21 @@ export async function adoptStack(owner, repo, prNumber, cfg) {
178
240
  }
179
241
  return body;
180
242
  }
243
+ /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
244
+ export async function ensureUpperPark(stackId, cfg) {
245
+ const body = await stackMutation(stackId, "/ensure-upper-park", "POST", {}, "Failed to ensure upper-park freeze", cfg);
246
+ if (typeof body !== "object" || body === null) {
247
+ throw new CommandError("Unexpected response shape from ensure-upper-park");
248
+ }
249
+ const freezeBranch = body.freezeBranch;
250
+ if (typeof freezeBranch !== "string" || !freezeBranch.trim()) {
251
+ throw new CommandError("ensure-upper-park did not return a freezeBranch");
252
+ }
253
+ return {
254
+ freezeBranch: freezeBranch.trim(),
255
+ created: body.created === true,
256
+ };
257
+ }
181
258
  async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg) {
182
259
  const resolved = cfg ?? (await loadConfig());
183
260
  const { status, body } = await apiFetch(resolved, `/api/v1/stacks/${encodeURIComponent(stackId)}${pathSuffix}`, method === "PATCH"
@@ -0,0 +1,5 @@
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";
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";
5
+ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
@@ -0,0 +1,5 @@
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";
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";
5
+ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
@@ -1,8 +1,18 @@
1
- import { getMe } from "../api.js";
1
+ import { getMe, listJobs } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
- import { formatResetLabel, usageBar } from "../ui/usage.js";
5
+ import { formatUsagePanel } from "../ui/usage.js";
6
+ import { relativeWhen } from "./jobs.js";
7
+ function toPanelJob(job) {
8
+ return {
9
+ job: job.job_id.slice(0, 8),
10
+ verdict: job.verdict ?? job.status ?? "-",
11
+ thread: job.thread_slug ?? "-",
12
+ credits: job.credits ? String(job.credits.standard) : "-",
13
+ when: relativeWhen(job.created_at),
14
+ };
15
+ }
6
16
  export async function cmdCredits(args) {
7
17
  const asJson = args.includes("--json");
8
18
  const cfg = await loadConfig();
@@ -11,18 +21,29 @@ export async function cmdCredits(args) {
11
21
  throw new CommandError("Credits are not available (not logged in, offline, or API too old). " +
12
22
  "Try `mergestorm login` or open https://mergestorm.ai/billing");
13
23
  }
24
+ let recentJobs = [];
25
+ try {
26
+ recentJobs = await listJobs(5, cfg);
27
+ }
28
+ catch {
29
+ recentJobs = [];
30
+ }
14
31
  if (asJson) {
15
- console.log(JSON.stringify({ usage: me.usage, resets_at: me.resets_at ?? null }, null, 2));
32
+ console.log(JSON.stringify({ usage: me.usage, resets_at: me.resets_at ?? null, recent_jobs: recentJobs }, null, 2));
16
33
  return;
17
34
  }
18
35
  const s = me.usage.standard;
19
- const p = me.usage.premium;
20
- // Claude Usage tab layout: label → long bar + "N% used" → Resets line.
21
- console.log(` ${ansi.bold("Standard")}`);
22
- console.log(` ${usageBar(s.used, s.limit)}`);
23
- console.log("");
24
- console.log(` ${ansi.bold("Premium")}`);
25
- console.log(` ${usageBar(p.used, p.limit)}`);
26
- console.log("");
27
- console.log(` ${ansi.dim(formatResetLabel(me.resets_at))}`);
36
+ const columns = process.stdout.columns || 80;
37
+ const lines = formatUsagePanel({
38
+ keyPrefix: me.key.prefix,
39
+ plan: me.plan_label_key ?? me.plan_key,
40
+ used: s.used,
41
+ limit: s.limit,
42
+ resetsAt: me.resets_at,
43
+ jobs: recentJobs.map(toPanelJob),
44
+ columns,
45
+ color: ansi.enabled,
46
+ });
47
+ for (const line of lines)
48
+ console.log(line);
28
49
  }
@@ -45,7 +45,7 @@ export async function cmdJobs(args) {
45
45
  console.log(ansi.dim(fmt(header)));
46
46
  for (const r of rows)
47
47
  console.log(fmt(r));
48
- console.log(ansi.dim(" branches: `branches` · full history: https://mergestorm.ai/dashboard/api"));
48
+ console.log(ansi.dim(" branches: `branches` · full history: https://mergestorm.ai/settings#api"));
49
49
  }
50
50
  /** Numbered chain timeline for a thread (branch/PR meta when present). */
51
51
  export function formatThreadTimeline(data) {
@@ -59,13 +59,29 @@ export async function cmdLogin(args) {
59
59
  const cfg = await loadConfig();
60
60
  const base = apiBase(cfg);
61
61
  let start;
62
- try {
63
- start = await devicePost(base, "/api/v1/cli/device", {});
64
- }
65
- catch {
66
- throw new CommandError("Could not start device login. The API may be unreachable or misconfigured.\n" +
67
- "Run `mergestorm login --key` to paste an existing API key instead.");
68
- }
62
+ let attempt = 0;
63
+ do {
64
+ try {
65
+ start = await devicePost(base, "/api/v1/cli/device", {});
66
+ }
67
+ catch {
68
+ throw new CommandError("Could not start device login. The API may be unreachable or misconfigured.\n" +
69
+ "Run `mergestorm login --key` to paste an existing API key instead.");
70
+ }
71
+ const err = start.body?.error;
72
+ if (err !== "rate_limited" && err !== "slow_down")
73
+ break;
74
+ if (start.status !== 429 && start.status !== 400)
75
+ break;
76
+ const backoff = Number(start.body?.retry_after_seconds);
77
+ const waitMs = Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : 5000;
78
+ if (attempt === 2) {
79
+ throw new CommandError(`Device login is rate limited for this IP. Wait ${Math.ceil(waitMs / 1000)}s, then run \`mergestorm login\` again.`);
80
+ }
81
+ console.log(`Device login is rate limited. Waiting ${Math.ceil(waitMs / 1000)}s before trying again…`);
82
+ await new Promise((r) => setTimeout(r, waitMs));
83
+ attempt += 1;
84
+ } while (attempt < 3);
69
85
  if (start.status !== 200 || !start.body?.device_code) {
70
86
  throw new CommandError(`Could not start device login (HTTP ${start.status}). The API may be unreachable or misconfigured.\n` +
71
87
  `Run \`mergestorm login --key\` to paste an existing API key instead.`);
@@ -100,8 +116,13 @@ export async function cmdLogin(args) {
100
116
  process.stdout.write(".");
101
117
  continue;
102
118
  }
119
+ if (err === "slow_down" || err === "rate_limited") {
120
+ const backoff = Number(poll.body?.retry_after_seconds);
121
+ await new Promise((r) => setTimeout(r, Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : pollMs));
122
+ continue;
123
+ }
103
124
  if (err === "key_limit_reached") {
104
- throw new CommandError("You have reached the max active API keys. Revoke one at https://mergestorm.ai/dashboard/api and try again.");
125
+ throw new CommandError("You have reached the max active API keys. Revoke one at https://mergestorm.ai/settings#api and try again.");
105
126
  }
106
127
  if (err === "expired_token") {
107
128
  throw new CommandError("Login code expired. Run `mergestorm login` again.");
@@ -22,7 +22,7 @@ export type RegistryCommand = {
22
22
  */
23
23
  export declare const COMMAND_REGISTRY: RegistryCommand[];
24
24
  export declare function findCommand(name: string): RegistryCommand | undefined;
25
- /** Autocomplete / `/help` list for the interactive shell. */
25
+ /** Autocomplete / `/help` list for the interactive shell. Aliases fold onto the primary name. */
26
26
  export declare function shellCommandSpecs(): CommandSpec[];
27
27
  export declare function formatUsageBody(): string;
28
28
  /**
@@ -41,7 +41,12 @@ export const COMMAND_REGISTRY = [
41
41
  name: "review",
42
42
  summary: "Review git diff (default origin/HEAD or main or master)",
43
43
  usage: [
44
- "mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)",
44
+ "mergestorm review [base] [head] Review git diff base...head",
45
+ " [--json] [--wait|--no-wait] [--timeout seconds]",
46
+ " [--router off|standard|max|manual] [--specialists id,id]",
47
+ " [--context text] [--context-file path] [--thread slug]",
48
+ " [--idempotency-key k] [--webhook-url https://…]",
49
+ " HTTP 429 exits 7 (rate_limited) with retry_after_seconds",
45
50
  ],
46
51
  async run(args, ctx) {
47
52
  if (ctx.mode === "shell") {
@@ -55,26 +60,27 @@ export const COMMAND_REGISTRY = [
55
60
  name: "status",
56
61
  summary: "Show a review job",
57
62
  usage: [
58
- "mergestorm status <job_id> Fetch a review job and print JSON (one shot)",
63
+ "mergestorm status <job_id> Fetch a review job (same envelope as review --json)",
64
+ " [--json] [--wait] [--timeout seconds]",
65
+ " HTTP 429 exits 7 (rate_limited) with retry_after_seconds",
59
66
  ],
60
67
  async run(args, ctx) {
61
- if (!args[0]) {
62
- throw new CommandError(ctx.mode === "shell"
63
- ? "usage: status <job_id>"
64
- : "usage: mergestorm status <job_id>");
65
- }
66
68
  if (ctx.mode === "shell") {
67
- await cmdStatus(args[0], { format: "pretty", signal: ctx.signal });
69
+ await cmdStatus(args, {
70
+ defaultFormat: "pretty",
71
+ signal: ctx.signal,
72
+ interactive: true,
73
+ });
68
74
  return;
69
75
  }
70
- await cmdStatus(args[0], { format: "json" });
76
+ await cmdStatus(args, { defaultFormat: "json" });
71
77
  },
72
78
  },
73
79
  {
74
80
  name: "credits",
75
81
  aliases: ["usage"],
76
- summary: "Credit balance with usage bars",
77
- usage: ["mergestorm credits [--json] Credit balance (usage bars)"],
82
+ summary: "Usage panel: credit bar and last five jobs",
83
+ usage: ["mergestorm credits [--json] Usage panel (bar + last 5 jobs)"],
78
84
  async run(args) {
79
85
  await cmdCredits(args);
80
86
  },
@@ -131,8 +137,8 @@ export const COMMAND_REGISTRY = [
131
137
  name: "stack",
132
138
  summary: "Stacks: create, submit, list, adopt, restack, land",
133
139
  usage: [
134
- "mergestorm stack create [name] New local stack layer (CLI-managed authoring state)",
135
- "mergestorm stack submit Push active local stack, open PRs, register stack",
140
+ "mergestorm stack create [name] New local stack layer ([--onto] [--trunk] [--extend])",
141
+ "mergestorm stack submit Push active local stack, open PRs ([--extend])",
136
142
  "mergestorm stack reset --force Clear this repo's pre-submit authoring state",
137
143
  "mergestorm stack list [--json] List registered stacks",
138
144
  "mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain",
@@ -168,18 +174,19 @@ export function findCommand(name) {
168
174
  const key = name.toLowerCase();
169
175
  return COMMAND_REGISTRY.find((c) => c.name === key || (c.aliases?.includes(key) ?? false));
170
176
  }
171
- /** Autocomplete / `/help` list for the interactive shell. */
177
+ /** Autocomplete / `/help` list for the interactive shell. Aliases fold onto the primary name. */
172
178
  export function shellCommandSpecs() {
173
179
  const specs = [{ name: "help", summary: SHELL_META[0].summary }];
174
180
  for (const c of COMMAND_REGISTRY) {
175
181
  if (SHELL_OMIT.has(c.name))
176
182
  continue;
177
- specs.push({ name: c.name, summary: c.summary });
178
- for (const alias of c.aliases ?? []) {
179
- specs.push({ name: alias, summary: c.summary });
180
- }
183
+ specs.push({
184
+ name: c.name,
185
+ summary: c.summary,
186
+ ...(c.aliases?.length ? { aliases: c.aliases } : {}),
187
+ });
181
188
  }
182
- specs.push({ name: "clear", summary: SHELL_META[1].summary }, { name: "exit", summary: SHELL_META[2].summary }, { name: "quit", summary: SHELL_META[3].summary });
189
+ specs.push({ name: "clear", summary: SHELL_META[1].summary }, { name: "exit", summary: SHELL_META[2].summary, aliases: ["quit"] });
183
190
  return specs;
184
191
  }
185
192
  export function formatUsageBody() {