mergestorm 0.3.13 → 0.3.16

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 (66) hide show
  1. package/README.md +16 -17
  2. package/dist/api.d.ts +103 -6
  3. package/dist/api.js +195 -9
  4. package/dist/automation-catalog.d.ts +37 -0
  5. package/dist/automation-catalog.js +13 -0
  6. package/dist/client/index.d.ts +3 -2
  7. package/dist/client/index.js +3 -2
  8. package/dist/commands/branches.d.ts +6 -2
  9. package/dist/commands/branches.js +29 -14
  10. package/dist/commands/browse.d.ts +39 -0
  11. package/dist/commands/browse.js +252 -0
  12. package/dist/commands/credits.js +7 -10
  13. package/dist/commands/help.d.ts +5 -0
  14. package/dist/commands/help.js +93 -0
  15. package/dist/commands/jobs.d.ts +11 -2
  16. package/dist/commands/jobs.js +64 -15
  17. package/dist/commands/login.js +4 -3
  18. package/dist/commands/logout.js +2 -1
  19. package/dist/commands/pr.d.ts +22 -0
  20. package/dist/commands/pr.js +127 -0
  21. package/dist/commands/queue.d.ts +12 -0
  22. package/dist/commands/queue.js +160 -0
  23. package/dist/commands/registry.js +98 -21
  24. package/dist/commands/review-client.d.ts +13 -0
  25. package/dist/commands/review-client.js +110 -0
  26. package/dist/commands/review.d.ts +6 -0
  27. package/dist/commands/review.js +18 -7
  28. package/dist/commands/settings.d.ts +17 -0
  29. package/dist/commands/settings.js +82 -0
  30. package/dist/commands/skill.d.ts +20 -0
  31. package/dist/commands/skill.js +145 -0
  32. package/dist/commands/stack.d.ts +57 -2
  33. package/dist/commands/stack.js +352 -87
  34. package/dist/commands/status.d.ts +2 -0
  35. package/dist/commands/status.js +30 -15
  36. package/dist/commands/whoami.d.ts +3 -1
  37. package/dist/commands/whoami.js +21 -7
  38. package/dist/errors.d.ts +1 -1
  39. package/dist/repo-identity.d.ts +1 -1
  40. package/dist/repo-identity.js +13 -2
  41. package/dist/shell.js +39 -31
  42. package/dist/stack-dto.d.ts +92 -1
  43. package/dist/stack-meta.d.ts +12 -0
  44. package/dist/stack-meta.js +32 -3
  45. package/dist/ui/ansi.d.ts +2 -0
  46. package/dist/ui/ansi.js +2 -0
  47. package/dist/ui/banner.d.ts +34 -2
  48. package/dist/ui/banner.js +166 -79
  49. package/dist/ui/findings.d.ts +2 -1
  50. package/dist/ui/findings.js +13 -7
  51. package/dist/ui/line-tabs.d.ts +27 -0
  52. package/dist/ui/line-tabs.js +170 -0
  53. package/dist/ui/logo.d.ts +10 -2
  54. package/dist/ui/logo.js +13 -9
  55. package/dist/ui/present.d.ts +10 -0
  56. package/dist/ui/present.js +13 -0
  57. package/dist/ui/prompt.d.ts +58 -1
  58. package/dist/ui/prompt.js +225 -42
  59. package/dist/ui/select.js +1 -1
  60. package/dist/ui/tabs.d.ts +125 -0
  61. package/dist/ui/tabs.js +480 -0
  62. package/dist/ui/usage.d.ts +4 -0
  63. package/dist/ui/usage.js +22 -2
  64. package/package.json +2 -1
  65. package/skill/SKILL.md +98 -0
  66. package/skill/mergestorm-pr-loop/SKILL.md +84 -0
@@ -0,0 +1,480 @@
1
+ import * as readline from "node:readline";
2
+ import { stdin, stdout } from "node:process";
3
+ import { ansi } from "./ansi.js";
4
+ import { frameWidth, preferBoxedUi, roundedBox, terminalColumns, } from "./box.js";
5
+ import { padVisible, visibleWidth } from "./width.js";
6
+ import { formatUsagePanel } from "./usage.js";
7
+ import { dropdownViewport } from "./prompt.js";
8
+ export const TAB_ORDER = ["status", "usage", "jobs", "config"];
9
+ const TAB_LABELS = {
10
+ status: "Status",
11
+ usage: "Usage",
12
+ jobs: "Jobs",
13
+ config: "Config",
14
+ };
15
+ export function initialTabsState(tab = "status") {
16
+ return {
17
+ tab,
18
+ scroll: { status: 0, usage: 0, detail: 0 },
19
+ jobsIndex: 0,
20
+ configIndex: 0,
21
+ detail: null,
22
+ };
23
+ }
24
+ function clampNum(value, lo, hi) {
25
+ return Math.min(Math.max(value, lo), Math.max(lo, hi));
26
+ }
27
+ /**
28
+ * One keypress against the browser state. Pure so tests can drive the whole
29
+ * key map without a TTY. Tabs clamp at the ends (no wrap-around, so "which
30
+ * tab am I on" never gets ambiguous); Esc backs out of a job detail before
31
+ * it closes the browser; `q` always closes.
32
+ */
33
+ export function reduceTabsKey(state, key, ctx) {
34
+ if (key === "q")
35
+ return { kind: "close" };
36
+ if (key === "escape") {
37
+ if (state.detail) {
38
+ return {
39
+ kind: "state",
40
+ state: { ...state, detail: null, scroll: { ...state.scroll, detail: 0 } },
41
+ };
42
+ }
43
+ return { kind: "close" };
44
+ }
45
+ if (key === "left" || key === "right") {
46
+ const at = TAB_ORDER.indexOf(state.tab);
47
+ const next = key === "left" ? Math.max(0, at - 1) : Math.min(TAB_ORDER.length - 1, at + 1);
48
+ return {
49
+ kind: "state",
50
+ state: {
51
+ ...state,
52
+ tab: TAB_ORDER[next],
53
+ detail: null,
54
+ scroll: { ...state.scroll, detail: 0 },
55
+ },
56
+ };
57
+ }
58
+ if (key === "up" || key === "down") {
59
+ const delta = key === "up" ? -1 : 1;
60
+ if (state.detail) {
61
+ return {
62
+ kind: "state",
63
+ state: {
64
+ ...state,
65
+ scroll: {
66
+ ...state.scroll,
67
+ detail: clampNum(state.scroll.detail + delta, 0, ctx.maxScroll),
68
+ },
69
+ },
70
+ };
71
+ }
72
+ if (state.tab === "jobs") {
73
+ return {
74
+ kind: "state",
75
+ state: {
76
+ ...state,
77
+ jobsIndex: clampNum(state.jobsIndex + delta, 0, Math.max(0, ctx.jobsCount - 1)),
78
+ },
79
+ };
80
+ }
81
+ if (state.tab === "config") {
82
+ const count = ctx.config?.length ?? 0;
83
+ return {
84
+ kind: "state",
85
+ state: {
86
+ ...state,
87
+ configIndex: clampNum(state.configIndex + delta, 0, Math.max(0, count - 1)),
88
+ },
89
+ };
90
+ }
91
+ return {
92
+ kind: "state",
93
+ state: {
94
+ ...state,
95
+ scroll: {
96
+ ...state.scroll,
97
+ [state.tab]: clampNum(state.scroll[state.tab] + delta, 0, ctx.maxScroll),
98
+ },
99
+ },
100
+ };
101
+ }
102
+ if (key === "return" && state.tab === "jobs" && !state.detail && ctx.jobsCount > 0) {
103
+ return { kind: "open-job", index: state.jobsIndex, state };
104
+ }
105
+ if (key === "space" && state.tab === "config" && !state.detail) {
106
+ const row = ctx.config?.[state.configIndex];
107
+ // Read-only rows (connected flags) ignore Space instead of erroring.
108
+ if (row?.writable) {
109
+ return { kind: "toggle-setting", key: row.key, value: !row.value, state };
110
+ }
111
+ }
112
+ return { kind: "state", state };
113
+ }
114
+ /** Body rows available inside the frame: total rows minus chrome + hint. */
115
+ export function tabsBodyRows(rows) {
116
+ const r = typeof rows === "number" && Number.isFinite(rows) && rows >= 1 ? rows : 24;
117
+ return Math.max(3, r - 4);
118
+ }
119
+ export function tabsMaxScroll(layout) {
120
+ return Math.max(0, layout.contentRows - layout.bodyRows);
121
+ }
122
+ function tabsTitle(state, color) {
123
+ const parts = TAB_ORDER.map((tab) => {
124
+ let label = TAB_LABELS[tab];
125
+ if (tab === "jobs" && state.detail) {
126
+ label = `${label} › ${state.detail.jobId.slice(0, 8)}`;
127
+ }
128
+ if (!color)
129
+ return tab === state.tab ? `[${label}]` : ` ${label} `;
130
+ return tab === state.tab ? ansi.boldBrightGreen(label) : ansi.dim(label);
131
+ });
132
+ return parts.join(color ? ansi.dim(" · ") : " · ");
133
+ }
134
+ function jobsBody(state, data, bodyRows, width, color) {
135
+ if (data.jobs.length === 0) {
136
+ const text = data.jobsEmptyText ?? "No review jobs yet. Run `review` to submit one.";
137
+ return [` ${color ? ansi.dim(text) : text}`];
138
+ }
139
+ const header = ["JOB", "STATUS", "VERDICT", "THREAD", "WHEN"];
140
+ const cells = data.jobs.map((j) => [
141
+ j.cells.job,
142
+ j.cells.status,
143
+ j.cells.verdict,
144
+ j.cells.thread,
145
+ j.cells.when,
146
+ ]);
147
+ const widths = header.map((h, i) => Math.max(h.length, ...cells.map((r) => r[i]?.length ?? 0)));
148
+ const fmt = (cols) => cols.map((c, i) => padVisible(c, widths[i])).join(" ");
149
+ const lines = [` ${color ? ansi.dim(fmt(header)) : fmt(header)}`];
150
+ const viewportRows = Math.max(1, bodyRows - 1);
151
+ const view = dropdownViewport(data.jobs.length, state.jobsIndex, viewportRows);
152
+ const end = Math.min(data.jobs.length, view.start + viewportRows);
153
+ for (let i = view.start; i < end; i++) {
154
+ const selected = i === state.jobsIndex;
155
+ const marker = selected ? "›" : " ";
156
+ const row = ` ${color && selected ? ansi.brightGreen(marker) : marker} ${fmt(cells[i])}`;
157
+ const clipped = visibleWidth(row) > width ? padVisible(row, width) : row;
158
+ lines.push(color && selected ? ansi.invert(clipped) : clipped);
159
+ }
160
+ return lines;
161
+ }
162
+ /** On/off rows for the Config tab; scrolls by selection like the jobs list. */
163
+ function configBody(state, data, bodyRows, width, color) {
164
+ if (!data.config || data.config.length === 0) {
165
+ const text = data.configEmptyText ?? "Settings unavailable (offline or older API).";
166
+ return [` ${color ? ansi.dim(text) : text}`];
167
+ }
168
+ const rows = data.config;
169
+ const labelWidth = Math.max(...rows.map((r) => r.label.length));
170
+ const noteRows = data.configNote ? 1 : 0;
171
+ const viewportRows = Math.max(1, bodyRows - noteRows);
172
+ const view = dropdownViewport(rows.length, state.configIndex, viewportRows);
173
+ const end = Math.min(rows.length, view.start + viewportRows);
174
+ const lines = [];
175
+ for (let i = view.start; i < end; i++) {
176
+ const row = rows[i];
177
+ const selected = i === state.configIndex;
178
+ const marker = selected ? "›" : " ";
179
+ const value = row.writable
180
+ ? row.value
181
+ ? "on"
182
+ : "off"
183
+ : row.value
184
+ ? "connected"
185
+ : "not connected";
186
+ const paintedValue = !color
187
+ ? value
188
+ : row.writable
189
+ ? row.value
190
+ ? ansi.brightGreen(value)
191
+ : ansi.dim(value)
192
+ : ansi.dim(value);
193
+ const line = ` ${color && selected ? ansi.brightGreen(marker) : marker} ${padVisible(row.label, labelWidth + 2)}${paintedValue}`;
194
+ const clipped = visibleWidth(line) > width ? padVisible(line, width) : line;
195
+ lines.push(color && selected ? ansi.invert(clipped) : clipped);
196
+ }
197
+ if (data.configNote) {
198
+ const note = ` ${data.configNote}`;
199
+ lines.push(color ? ansi.dim(note) : note);
200
+ }
201
+ return lines;
202
+ }
203
+ /** Key hint on the left, optional reachability note on the right. */
204
+ export function tabsHintLine(left, right, columns, color) {
205
+ const room = Math.max(1, columns - 1);
206
+ const L = ` ${color ? ansi.dim(left) : left}`;
207
+ if (!right) {
208
+ return visibleWidth(L) > room ? padVisible(L, room) : L;
209
+ }
210
+ const R = color ? ansi.dim(right) : right;
211
+ if (visibleWidth(L) + 1 + visibleWidth(R) > room) {
212
+ const keep = Math.max(0, room - visibleWidth(R) - 1);
213
+ const clippedL = padVisible(L, keep);
214
+ const gap = Math.max(1, room - visibleWidth(clippedL) - visibleWidth(R));
215
+ return `${clippedL}${" ".repeat(gap)}${R}`;
216
+ }
217
+ return `${L}${" ".repeat(Math.max(1, room - visibleWidth(L) - visibleWidth(R)))}${R}`;
218
+ }
219
+ /**
220
+ * The whole browser frame for one render: tab strip in the top border, the
221
+ * active tab's body clipped to `rows - 4`, and a dim key hint. Pure; the
222
+ * raw-mode loop and tests both consume it.
223
+ */
224
+ export function layoutTabs(state, data, columns, rows) {
225
+ const color = data.color ?? false;
226
+ const boxed = preferBoxedUi(columns);
227
+ const cols = terminalColumns(80, columns);
228
+ const outer = frameWidth(80, columns);
229
+ const contentCells = boxed ? Math.max(20, outer - 4) : Math.max(20, cols - 1);
230
+ const bodyRows = tabsBodyRows(rows);
231
+ const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
232
+ let content;
233
+ let contentRows;
234
+ let scroll = 0;
235
+ if (state.detail) {
236
+ content = state.detail.rows;
237
+ contentRows = content.length;
238
+ scroll = state.scroll.detail;
239
+ }
240
+ else if (state.tab === "status") {
241
+ content = data.statusLines;
242
+ contentRows = content.length;
243
+ scroll = state.scroll.status;
244
+ }
245
+ else if (state.tab === "usage") {
246
+ const usageEmpty = data.usageEmptyText ?? "Usage unavailable (offline or older API).";
247
+ content = data.usage
248
+ ? formatUsagePanel({ ...data.usage, columns: contentCells, color })
249
+ : [` ${color ? ansi.dim(usageEmpty) : usageEmpty}`];
250
+ contentRows = content.length;
251
+ scroll = state.scroll.usage;
252
+ }
253
+ else if (state.tab === "config") {
254
+ content = configBody(state, data, bodyRows, contentCells, color);
255
+ // Config scrolls by selection inside configBody; no body scroll.
256
+ contentRows = content.length;
257
+ scroll = 0;
258
+ }
259
+ else {
260
+ content = jobsBody(state, data, bodyRows, contentCells, color);
261
+ // The jobs list scrolls by selection inside jobsBody; no body scroll.
262
+ contentRows = content.length;
263
+ scroll = 0;
264
+ }
265
+ const start = clampNum(scroll, 0, Math.max(0, contentRows - bodyRows));
266
+ const body = content.slice(start, start + bodyRows).map(clip);
267
+ // Constant frame height regardless of tab, so redraws replace every row.
268
+ while (body.length < bodyRows)
269
+ body.push("");
270
+ const hint = state.detail
271
+ ? "↑↓ scroll · esc back to jobs · q quit"
272
+ : state.tab === "jobs"
273
+ ? "←→ tabs · ↑↓ select · enter open · q close"
274
+ : state.tab === "config"
275
+ ? "←→ tabs · ↑↓ select · space toggle · q close"
276
+ : "←→ tabs · ↑↓ scroll · esc close";
277
+ const clippedHint = tabsHintLine(hint, data.footerNote, cols, color);
278
+ const title = tabsTitle(state, color);
279
+ if (!boxed) {
280
+ const plainTitle = visibleWidth(title) > cols - 1 ? padVisible(title, cols - 1) : title;
281
+ return {
282
+ lines: [plainTitle, ...body, clippedHint],
283
+ bodyRows,
284
+ contentRows,
285
+ };
286
+ }
287
+ return {
288
+ lines: [
289
+ ...roundedBox(body, { title, width: outer, padding: 1 }),
290
+ clippedHint,
291
+ ],
292
+ bodyRows,
293
+ contentRows,
294
+ };
295
+ }
296
+ const up = (n) => `\u001b[${n}A`;
297
+ const clearDown = "\u001b[0J";
298
+ /** Resize redraw clears from row 1: old rows may have wrapped (see prompt.ts). */
299
+ const homeClear = `\u001b[H${clearDown}`;
300
+ /**
301
+ * Raw-mode tabbed browser (Status / Usage / Jobs / Config) on the select.ts
302
+ * loop: Left/Right switch tabs, Up/Down scroll or select, Enter opens a job
303
+ * in place, Space toggles a Config setting, `q`/Esc close, resize re-renders.
304
+ * Non-TTY streams fall back to the static panels (callers normally gate on
305
+ * TTY already).
306
+ */
307
+ export async function runTabsBrowser(opts) {
308
+ const input = opts.input ?? stdin;
309
+ const output = opts.output ?? stdout;
310
+ const data = opts.data;
311
+ if (!input.isTTY || !output.isTTY) {
312
+ const layout = layoutTabs(initialTabsState(opts.initial ?? "status"), data, output.columns, output.rows);
313
+ for (const line of layout.lines)
314
+ console.log(line);
315
+ return;
316
+ }
317
+ return new Promise((resolve) => {
318
+ let state = initialTabsState(opts.initial ?? "status");
319
+ let lastLayout = null;
320
+ let linesDrawn = 0;
321
+ let finished = false;
322
+ let detailSeq = 0;
323
+ let toggleSeq = 0;
324
+ const wasRaw = Boolean(input.isRaw);
325
+ readline.emitKeypressEvents(input);
326
+ input.setRawMode(true);
327
+ input.resume();
328
+ function render(prefix) {
329
+ if (prefix) {
330
+ output.write(prefix);
331
+ }
332
+ else if (linesDrawn > 0) {
333
+ output.write(`${up(linesDrawn)}\r${clearDown}`);
334
+ }
335
+ lastLayout = layoutTabs(state, data, output.columns, output.rows);
336
+ output.write(lastLayout.lines.join("\r\n") + "\r\n");
337
+ linesDrawn = lastLayout.lines.length;
338
+ }
339
+ function onResize() {
340
+ if (!finished)
341
+ render(homeClear);
342
+ }
343
+ function cleanup() {
344
+ input.removeListener("keypress", onKeypress);
345
+ output.removeListener("resize", onResize);
346
+ input.setRawMode(wasRaw);
347
+ input.pause();
348
+ }
349
+ function finish() {
350
+ if (finished)
351
+ return;
352
+ finished = true;
353
+ cleanup();
354
+ // Erase the frame so the shell prompt reclaims the space.
355
+ if (linesDrawn > 0)
356
+ output.write(`${up(linesDrawn)}\r${clearDown}`);
357
+ resolve();
358
+ }
359
+ function openJob(index) {
360
+ const job = data.jobs[index];
361
+ if (!job || !opts.loadJobDetail)
362
+ return;
363
+ const seq = ++detailSeq;
364
+ state = {
365
+ ...state,
366
+ detail: {
367
+ jobId: job.id,
368
+ rows: [` Loading job ${job.id.slice(0, 8)}…`],
369
+ },
370
+ scroll: { ...state.scroll, detail: 0 },
371
+ };
372
+ render();
373
+ opts
374
+ .loadJobDetail(job.id)
375
+ .then((rows) => {
376
+ if (finished || seq !== detailSeq)
377
+ return;
378
+ if (!state.detail || state.detail.jobId !== job.id)
379
+ return;
380
+ state = { ...state, detail: { jobId: job.id, rows } };
381
+ render();
382
+ })
383
+ .catch((err) => {
384
+ if (finished || seq !== detailSeq)
385
+ return;
386
+ if (!state.detail || state.detail.jobId !== job.id)
387
+ return;
388
+ const message = err instanceof Error ? err.message : String(err);
389
+ state = {
390
+ ...state,
391
+ detail: { jobId: job.id, rows: [ansi.red(` ${message}`)] },
392
+ };
393
+ render();
394
+ });
395
+ }
396
+ function applyToggle(key, value) {
397
+ if (!opts.toggleSetting)
398
+ return;
399
+ const seq = ++toggleSeq;
400
+ data.configNote = "saving…";
401
+ render();
402
+ opts
403
+ .toggleSetting(key, value)
404
+ .then((rows) => {
405
+ if (finished || seq !== toggleSeq)
406
+ return;
407
+ data.config = rows;
408
+ delete data.configNote;
409
+ render();
410
+ })
411
+ .catch((err) => {
412
+ if (finished || seq !== toggleSeq)
413
+ return;
414
+ // Keep the stored rows; only the note reports the rejection.
415
+ data.configNote = err instanceof Error ? err.message : String(err);
416
+ render();
417
+ });
418
+ }
419
+ function onKeypress(str, key) {
420
+ if (finished || !key)
421
+ return;
422
+ if (key.ctrl && key.name === "c") {
423
+ finish();
424
+ return;
425
+ }
426
+ let name = null;
427
+ if (key.name === "left" || key.name === "right" || key.name === "up" || key.name === "down") {
428
+ name = key.name;
429
+ }
430
+ else if (key.name === "return") {
431
+ name = "return";
432
+ }
433
+ else if (key.name === "space" || str === " ") {
434
+ name = "space";
435
+ }
436
+ else if (key.name === "escape") {
437
+ name = "escape";
438
+ }
439
+ else if (key.name === "q" || str === "q") {
440
+ name = "q";
441
+ }
442
+ if (!name)
443
+ return;
444
+ const step = reduceTabsKey(state, name, {
445
+ jobsCount: data.jobs.length,
446
+ maxScroll: lastLayout ? tabsMaxScroll(lastLayout) : 0,
447
+ config: data.config ?? [],
448
+ });
449
+ if (step.kind === "close") {
450
+ finish();
451
+ return;
452
+ }
453
+ if (step.kind === "open-job") {
454
+ openJob(step.index);
455
+ return;
456
+ }
457
+ if (step.kind === "toggle-setting") {
458
+ applyToggle(step.key, step.value);
459
+ return;
460
+ }
461
+ state = step.state;
462
+ try {
463
+ render();
464
+ }
465
+ catch {
466
+ finish();
467
+ }
468
+ }
469
+ input.on("keypress", onKeypress);
470
+ output.on("resize", onResize);
471
+ try {
472
+ // First paint homes so we do not write from the idle input row
473
+ // (mid-pane) and scroll the welcome chrome.
474
+ render(homeClear);
475
+ }
476
+ catch {
477
+ finish();
478
+ }
479
+ });
480
+ }
@@ -20,6 +20,8 @@ export declare function nextUtcMonthStart(now?: Date): string;
20
20
  * Falls back to next UTC month start when `iso` is missing/invalid.
21
21
  */
22
22
  export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
23
+ /** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
24
+ export declare function formatDaysLeft(iso: string | null | undefined, now?: Date): string;
23
25
  /** Alias kept for callers that still import `formatResetDate`. */
24
26
  export declare function formatResetDate(iso: string | null | undefined): string;
25
27
  /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
@@ -38,6 +40,8 @@ export type UsagePanelInput = {
38
40
  limit: number | null;
39
41
  resetsAt: string | null | undefined;
40
42
  jobs: UsagePanelJob[];
43
+ /** When the jobs fetch failed, show this instead of "No review jobs yet." */
44
+ jobsError?: string;
41
45
  columns: number;
42
46
  color?: boolean;
43
47
  };
package/dist/ui/usage.js CHANGED
@@ -30,7 +30,7 @@ export function usageBar(used, limit, opts = {}) {
30
30
  if (!color) {
31
31
  return `[${filledSeg}${emptySeg}]${suffix}`;
32
32
  }
33
- let fillColor = ansi.green;
33
+ let fillColor = ansi.brightGreen;
34
34
  if (ratio >= 0.9)
35
35
  fillColor = ansi.red;
36
36
  else if (ratio >= 0.75)
@@ -63,6 +63,26 @@ export function formatResetLabel(iso, now = new Date()) {
63
63
  const mm = String(m).padStart(2, "0");
64
64
  return `Resets ${month} ${day}, ${h}:${mm}${ampm} (UTC)`;
65
65
  }
66
+ /** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
67
+ export function formatDaysLeft(iso, now = new Date()) {
68
+ if (!iso)
69
+ return "";
70
+ const d = new Date(iso);
71
+ if (Number.isNaN(d.getTime()))
72
+ return "";
73
+ const ms = d.getTime() - now.getTime();
74
+ if (ms <= 0)
75
+ return "reset due";
76
+ const days = Math.floor(ms / 86_400_000);
77
+ const hours = Math.floor((ms % 86_400_000) / 3_600_000);
78
+ if (days >= 2)
79
+ return `${days} days left`;
80
+ if (days === 1)
81
+ return "1 day left";
82
+ if (hours >= 1)
83
+ return `${hours}h left`;
84
+ return "resets soon";
85
+ }
66
86
  /** Alias kept for callers that still import `formatResetDate`. */
67
87
  export function formatResetDate(iso) {
68
88
  return formatResetLabel(iso);
@@ -95,7 +115,7 @@ export function formatUsagePanel(input) {
95
115
  "",
96
116
  ];
97
117
  if (input.jobs.length === 0) {
98
- lines.push(" No review jobs yet.");
118
+ lines.push(input.jobsError ? ` ${input.jobsError}` : " No review jobs yet.");
99
119
  return lines.map((line) => clipLine(line, columns));
100
120
  }
101
121
  const inner = Math.max(20, columns - 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.13",
3
+ "version": "0.3.16",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "dist",
35
+ "skill",
35
36
  "LICENSE",
36
37
  "README.md"
37
38
  ],
package/skill/SKILL.md ADDED
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: mergestorm-review
3
+ description: Review a local branch with Mergestorm Vortex specialists before opening a PR, then apply the findings yourself.
4
+ ---
5
+
6
+ # Mergestorm review
7
+
8
+ Use this skill to review a local git branch with Mergestorm, then apply the findings yourself.
9
+
10
+ ## When to use
11
+
12
+ Before opening a PR; after a large refactor; when the user says review, audit, second opinion, or "is this safe to merge".
13
+
14
+ Not for merging, landing, or pushing. For a PR that is already open on GitHub, use the `mergestorm-pr-loop` skill instead.
15
+
16
+ ## Install (Claude Code / Cursor)
17
+
18
+ ```bash
19
+ mg skill install --claude
20
+ mg skill install --cursor
21
+ # or both: mg skill install --claude --cursor
22
+ ```
23
+
24
+ That writes `.claude/skills/mergestorm-review/SKILL.md` and/or `.cursor/skills/mergestorm-review/SKILL.md` in the current repo.
25
+
26
+ Hand-copy still works: Claude Code project skill `.claude/skills/mergestorm-review/SKILL.md` (or `~/.claude/skills/mergestorm-review/SKILL.md`). Cursor: the same file under `.cursor/skills/mergestorm-review/SKILL.md`; otherwise a rule at `.cursor/rules/mergestorm-review.mdc` with this description and `alwaysApply: false`.
27
+
28
+ MCP server:
29
+
30
+ ```bash
31
+ curl -fsSL https://mergestorm.ai/install.sh | bash -s -- --mcp
32
+ # or: npm i -g mergestorm mergestorm-mcp
33
+ claude mcp add mergestorm -- npx -y mergestorm-mcp
34
+ ```
35
+
36
+ Cursor `~/.cursor/mcp.json`:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "mergestorm": {
42
+ "command": "npx",
43
+ "args": ["-y", "mergestorm-mcp"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Preconditions
50
+
51
+ 1. Prefer the `mergestorm` MCP server. Call `whoami`. If MCP is missing, require `mg` on PATH with `mg whoami --json` exit 0. If neither is available, stop and print the install one-liner above.
52
+ 2. Call `credits` (or `mg credits --json`). If remaining standard credits are 0, say so and stop.
53
+
54
+ ## Hire policy
55
+
56
+ Default: omit `router` (account setting).
57
+
58
+ - `router: "off"` for tiny diffs or docs-only
59
+ - `manual` with pins when the change is clearly one domain: `security` (auth/crypto/input), `frontend` (components), `data` (migrations), `api` (route contracts), `tests` (mostly tests), `performance` (hot paths), `architecture` (cross-module moves)
60
+ - `max` only when the user asks for a full pass
61
+ - Never request `governance` or `seam`. Neither exists for local reviews. Seam runs automatically during Mergestorm stack promotes and is controlled by the Agents settings toggle (default on).
62
+
63
+ ## Submit
64
+
65
+ Call `review_submit` on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review — never from outside it. Do not paste the diff into context.
66
+
67
+ Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`. Default wait is 300s; on timeout the tool returns `status: in_progress` and the job id — then call `review_wait`.
68
+
69
+ Use `review_get` or `review_list` to resume a known job.
70
+
71
+ Status handling:
72
+
73
+ - status is `no_changes`: success. The branch matches the selected base, no review job was submitted, and no review credit was used. Stop unless the user expected a different base.
74
+ - status is `rate_limited`: wait `retry_after_seconds`. Use `review_list`, `review_get`, or `review_wait` for an existing in-flight job before resubmitting. Do not immediately resubmit the same diff.
75
+
76
+ ## Apply findings
77
+
78
+ Read `findings.inline` (and `off_diff` / `offDiff`) sorted by severity. Fix or explain each. Never silence a finding by deleting the test. Then submit again on the same branch so the thread chains, with `context` stating what was addressed.
79
+
80
+ ## Never
81
+
82
+ - land, merge, push, or open a PR
83
+ - call stack authoring or land commands
84
+ - wrap the CLI in a shell escape hatch
85
+ - claim a finding is fixed without a diff that touches its path
86
+ - resubmit an identical failing diff (that burns a credit)
87
+ - treat `no_changes` or `rate_limited` as a failed review that needs an immediate resubmit
88
+ - pass `--context-file` or `--context` contents from outside the repo under review
89
+
90
+ ## Fallback (no MCP)
91
+
92
+ ```bash
93
+ mg review --json --context "…"
94
+ # optional: --router … --specialists … --context-file … --thread … --wait / --no-wait --timeout
95
+ # context files must live inside the repo under review
96
+ ```
97
+
98
+ Parse stdout as one JSON object (`mergestorm.review_job/v1`). Exit 0 includes `no_changes`. Exit 5 means detach and poll with `mg status <id> --json --wait`. Exit 7 means `rate_limited`: wait `retry_after_seconds` before trying again.