mergestorm 0.3.63 → 0.3.64

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.
package/README.md CHANGED
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.63 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.64 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
52
52
  │ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
53
- │ ▀ New in v0.3.63: Stack create starts a new stack unless --extend│
53
+ │ ▀ New in v0.3.64: Memory commands and Overdrive merge batches │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -113,7 +113,8 @@ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --
113
113
  - It prints nothing while the stack is `waiting` or `in_progress`, including while Cyclone or Vortex holds the blocked PR (`busy[]`, `actAfter: "agents_idle"`).
114
114
  - On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3. Attention names the lowest layer with a blocker an agent must fix, on any layer of the stack, not only the current candidate.
115
115
  - A confirmed landing prints `MS-WATCH LANDED stack=<id> reason=landed` and exits 0. `closed`, `archived` and `not_found` end the watch with `MS-WATCH ATTENTION stack=<id> reason=<reason>` and exit 3. A missing stack does not prove a landing; verify each PR before reporting it merged.
116
- - A failed read is retried once. If the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. A rate limit is waited out.
116
+ - A permanent read error is retried once; if the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. Timeouts, 5xx and rate limits are retried, but after 15 minutes with no stack read it prints the same line and exits 3.
117
+ - A watch that cannot start (bad flags, a config it cannot read, a crash) prints `MS-WATCH ATTENTION failed` too, so the command never exits without an `MS-WATCH` line unless you stop it (Ctrl+C or an abort, exit 130). Pass each flag as its own word: in zsh an unquoted `$args` holding several flags stays one word and the command refuses it.
117
118
  - `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
118
119
  - `--ignore <text>` keeps it silent on the first attention whose blocker contains that text (case-insensitive), until the blocker, its PR or its head changes. Use it for a blocker a human has taken over. Repeat the flag for more than one.
119
120
  - `--until landed` keeps running after an attention, printing each new attention once, and exits only when the watch is done or reads keep failing.
package/dist/api.d.ts CHANGED
@@ -182,13 +182,18 @@ export type SettingsResponse = import("./automation-catalog.js").BearerSettingsV
182
182
  github_connected: boolean;
183
183
  stacks_ready?: boolean;
184
184
  };
185
- export declare const REPO_SETTINGS_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default"];
185
+ export declare const REPO_SETTINGS_SWITCH_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive"];
186
+ export declare const REPO_SETTINGS_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive", "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
186
187
  export type RepoSettingsKey = (typeof REPO_SETTINGS_KEYS)[number];
188
+ export type RepoSettingsValues = Record<(typeof REPO_SETTINGS_SWITCH_KEYS)[number], boolean | null> & {
189
+ merge_queue_batch_size: number | null;
190
+ merge_queue_lands_alone_paths: string[] | null;
191
+ };
187
192
  export type RepoSettings = {
188
193
  owner: string;
189
194
  repo: string;
190
- } & Record<RepoSettingsKey, boolean | null>;
191
- export type RepoSettingsPatch = Partial<Record<RepoSettingsKey, boolean | null>>;
195
+ } & RepoSettingsValues;
196
+ export type RepoSettingsPatch = Partial<RepoSettingsValues>;
192
197
  export declare function listRepoSettings(cfg?: Config, opts?: {
193
198
  timeoutMs?: number;
194
199
  }): Promise<RepoSettings[]>;
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { apiBase, loadConfig, resolveApiKey } from "./config.js";
1
+ import { apiBase, loadConfig, resolveApiKey, resolveChatId } from "./config.js";
2
2
  import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, } from "./automation-catalog.js";
3
3
  import { CommandError, REVIEW_EXIT, rateLimitedMessage, plainServerText, } from "./errors.js";
4
4
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
@@ -84,8 +84,10 @@ export async function apiFetch(cfg, route, init = {}) {
84
84
  throw new CommandError("No API key. Run `mergestorm login` (agents: `mergestorm login --start`, then `--finish`) or set MERGESTORM_API_KEY.", 1, "missing_api_key");
85
85
  }
86
86
  const { json, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: userSignal, ...rest } = init;
87
+ const chatId = resolveChatId(cfg);
87
88
  const headers = {
88
89
  Authorization: `Bearer ${key}`,
90
+ ...(chatId ? { "x-mergestorm-chat-id": chatId } : {}),
89
91
  ...rest.headers,
90
92
  };
91
93
  let body = rest.body;
@@ -172,7 +174,14 @@ export function applyPrReviewQuery(query, opts) {
172
174
  * The connected flags are read-only there and deliberately absent here.
173
175
  */
174
176
  export { SETTINGS_WRITABLE_KEYS };
175
- export const REPO_SETTINGS_KEYS = ["auto_patch_enabled", "auto_review_enabled", "auto_land_default"];
177
+ export const REPO_SETTINGS_SWITCH_KEYS = [
178
+ "auto_patch_enabled",
179
+ "auto_review_enabled",
180
+ "auto_land_default",
181
+ "merge_queue_batch_enabled",
182
+ "merge_queue_overdrive",
183
+ ];
184
+ export const REPO_SETTINGS_KEYS = [...REPO_SETTINGS_SWITCH_KEYS, "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
176
185
  function repoSettingsFailure(status, body, retryAfterSeconds, action) {
177
186
  if (status === 429) {
178
187
  return new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", {
@@ -63,7 +63,17 @@ export declare const BEARER_SETTINGS: readonly [{
63
63
  readonly label: "Merge queue batch size (pull requests)";
64
64
  readonly kind: "count";
65
65
  readonly min: 2;
66
- readonly max: 8;
66
+ readonly max: 10;
67
+ }, {
68
+ readonly key: "merge_queue_lands_alone_paths";
69
+ readonly flag: "--merge-queue-lands-alone";
70
+ readonly label: "Merge queue lands-alone paths";
71
+ readonly kind: "paths";
72
+ readonly defaults: readonly ["**/migrations/**", "db/migrate/**", ".github/workflows/**"];
73
+ }, {
74
+ readonly key: "merge_queue_overdrive";
75
+ readonly flag: "--merge-queue-overdrive";
76
+ readonly label: "Merge queue overdrive (up to 10 per batch)";
67
77
  }, {
68
78
  readonly key: "ignored_bot_logins";
69
79
  readonly flag: "--ignore-bot";
@@ -95,11 +105,11 @@ export declare const BEARER_SETTINGS_LABELS: Record<BearerSettingsKey, string>;
95
105
  /** Omitted kind means boolean for the original toggle rows. */
96
106
  export type BearerSettingsValues = {
97
107
  [Row in (typeof BEARER_SETTINGS)[number] as Row["key"]]: Row extends {
98
- kind: "logins";
108
+ kind: "logins" | "paths";
99
109
  } ? string[] : Row extends {
100
110
  kind: "seconds" | "count";
101
111
  } ? number : Row extends {
102
112
  values: readonly (infer Value)[];
103
113
  } ? Value : boolean;
104
114
  };
105
- export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
115
+ export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
@@ -13,7 +13,9 @@ export const BEARER_SETTINGS = [
13
13
  { key: "auto_land_default", flag: "--auto-land", label: "Auto land (new stacks)" },
14
14
  { key: "auto_land_settle_seconds", flag: "--auto-land-settle", label: "Auto land settle (seconds)", kind: "seconds", min: 15, max: 300 },
15
15
  { key: "merge_queue_batch_enabled", flag: "--merge-queue-batch", label: "Merge queue batching" },
16
- { key: "merge_queue_batch_size", flag: "--merge-queue-batch-size", label: "Merge queue batch size (pull requests)", kind: "count", min: 2, max: 8 },
16
+ { key: "merge_queue_batch_size", flag: "--merge-queue-batch-size", label: "Merge queue batch size (pull requests)", kind: "count", min: 2, max: 10 },
17
+ { key: "merge_queue_lands_alone_paths", flag: "--merge-queue-lands-alone", label: "Merge queue lands-alone paths", kind: "paths", defaults: ["**/migrations/**", "db/migrate/**", ".github/workflows/**"] },
18
+ { key: "merge_queue_overdrive", flag: "--merge-queue-overdrive", label: "Merge queue overdrive (up to 10 per batch)" },
17
19
  { key: "ignored_bot_logins", flag: "--ignore-bot", label: "Ignored bot logins", kind: "logins" },
18
20
  { key: "vortex_bot_skip_check", flag: "--vortex-skip-check", label: "Vortex bot skip check", kind: "enum", values: ["none", "neutral"] },
19
21
  { key: "vortex_findings_check", flag: "--vortex-findings", label: "Vortex findings check", kind: "enum", values: ["failure", "neutral", "success"] },
@@ -8,6 +8,7 @@ export { discoverTrunk } from "../git-stack.js";
8
8
  export { parseGithubOriginRepo } from "../git.js";
9
9
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
10
10
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
11
- export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, autoFixCiActive, autoResolveActive, type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
11
+ export { BREEZE_BRANCH_PREFIX, stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, autoFixCiActive, autoResolveActive, type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
12
12
  export { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackWatchBackgroundCommand, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
13
- export { publishBranchServerSide, publishWorkingBranch, workingBranchPublication, type ServerPublishResult, type WorkingBranchInput, } from "../server-publish.js";
13
+ export { READ_ONLY_GIT, publishBranchServerSide, publishWorkingBranch, workingBranchPublication, type GitRead, type ServerPublishDeps, type ServerPublishInput, type ServerPublishResult, type WorkingBranchInput, } from "../server-publish.js";
14
+ export { MEMORY_KINDS, MEMORY_MODES, MEMORY_SCOPES, MEMORY_SEARCH_LIMIT_MAX, deleteMemory, getMemoryMode, listMemories, memoryChatId, saveMemory, searchMemories, setMemoryMode, updateMemory, type MemoryItem, type MemoryKind, type MemoryList, type MemoryMode, type MemoryScope, type MemorySearch, } from "../memory-api.js";
@@ -8,6 +8,7 @@ export { discoverTrunk } from "../git-stack.js";
8
8
  export { parseGithubOriginRepo } from "../git.js";
9
9
  export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
10
10
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
11
- export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, autoFixCiActive, autoResolveActive, } from "../stack-blockers.js";
11
+ export { BREEZE_BRANCH_PREFIX, stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, agentsCannotClear, autoFixCiActive, autoResolveActive, } from "../stack-blockers.js";
12
12
  export { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackWatchBackgroundCommand, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
13
- export { publishBranchServerSide, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
13
+ export { READ_ONLY_GIT, publishBranchServerSide, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
14
+ export { MEMORY_KINDS, MEMORY_MODES, MEMORY_SCOPES, MEMORY_SEARCH_LIMIT_MAX, deleteMemory, getMemoryMode, listMemories, memoryChatId, saveMemory, searchMemories, setMemoryMode, updateMemory, } from "../memory-api.js";
@@ -27,7 +27,7 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
27
27
  /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
28
28
  export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
29
  /** Human labels for the Config tab and the static `settings` printout. */
30
- export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
30
+ export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
31
31
  export declare const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
32
32
  export declare const STACKS_READY_WORDS: {
33
33
  readonly on: "ready";
@@ -0,0 +1,30 @@
1
+ import { type Config } from "../config.js";
2
+ import { deleteMemory, getMemoryMode, listMemories, saveMemory, searchMemories, setMemoryMode, updateMemory, type MemoryItem, type MemoryKind, type MemoryScope } from "../memory-api.js";
3
+ export declare const MEMORY_USAGE: string;
4
+ export declare const MEMORY_SUBCOMMANDS: readonly ["list", "search", "add", "update", "delete", "mode"];
5
+ type Flags = {
6
+ positional: string[];
7
+ json: boolean;
8
+ repo?: string;
9
+ kind?: MemoryKind;
10
+ scope?: MemoryScope;
11
+ limit?: number;
12
+ };
13
+ export declare function parseMemoryFlags(args: string[], allowed: ReadonlyArray<"repo" | "kind" | "scope" | "limit">): Flags;
14
+ export declare function formatMemoryLine(memory: MemoryItem): string;
15
+ export type MemoryDeps = {
16
+ loadConfig?: () => Promise<Config>;
17
+ chatId?: string | null;
18
+ print?: (line: string) => void;
19
+ api?: Partial<{
20
+ listMemories: typeof listMemories;
21
+ searchMemories: typeof searchMemories;
22
+ saveMemory: typeof saveMemory;
23
+ updateMemory: typeof updateMemory;
24
+ deleteMemory: typeof deleteMemory;
25
+ getMemoryMode: typeof getMemoryMode;
26
+ setMemoryMode: typeof setMemoryMode;
27
+ }>;
28
+ };
29
+ export declare function cmdMemory(args: string[], deps?: MemoryDeps): Promise<void>;
30
+ export {};
@@ -0,0 +1,152 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { CommandError, REVIEW_EXIT } from "../errors.js";
3
+ import { MEMORY_KINDS, MEMORY_SEARCH_LIMIT_MAX, deleteMemory, getMemoryMode, isMemoryKind, isMemoryMode, isMemoryScope, listMemories, memoryChatId, saveMemory, searchMemories, setMemoryMode, updateMemory, } from "../memory-api.js";
4
+ export const MEMORY_USAGE = [
5
+ "usage: mergestorm memory list [--scope user|repo] [--repo <owner/repo>] [--kind <kind>] [--json]",
6
+ " mergestorm memory search <query> [--repo <owner/repo>] [--limit <1-20>] [--json]",
7
+ " mergestorm memory add <text> --kind <kind> [--repo <owner/repo>] [--json]",
8
+ " mergestorm memory update <id> <text> [--kind <kind>] [--json]",
9
+ " mergestorm memory delete <id> [--json]",
10
+ " mergestorm memory mode [off|on_request|all] [--json]",
11
+ ` kind is one of ${MEMORY_KINDS.join(", ")}. With --repo a memory is about that repository, otherwise about you.`,
12
+ " search covers your memories, and with --repo that repository's memories and standing rules too.",
13
+ " Inside a Breeze chat (MERGESTORM_CHAT_ID), add and search follow that chat's memory setting.",
14
+ ].join("\n");
15
+ export const MEMORY_SUBCOMMANDS = ["list", "search", "add", "update", "delete", "mode"];
16
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
17
+ function usage(message) {
18
+ return new CommandError(message ? `${message}\n${MEMORY_USAGE}` : MEMORY_USAGE, REVIEW_EXIT.usage, "usage");
19
+ }
20
+ export function parseMemoryFlags(args, allowed) {
21
+ const flags = { positional: [], json: false };
22
+ const value = (flag, raw) => {
23
+ if (raw === undefined || raw.startsWith("--"))
24
+ throw usage(`${flag} needs a value`);
25
+ return raw.trim();
26
+ };
27
+ for (let i = 0; i < args.length; i++) {
28
+ const arg = args[i];
29
+ const name = arg.startsWith("--") ? arg.slice(2) : null;
30
+ if (arg === "--json")
31
+ flags.json = true;
32
+ else if (name === "repo" && allowed.includes("repo")) {
33
+ const repo = value(arg, args[++i]).replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/i, "");
34
+ if (!REPO.test(repo))
35
+ throw usage("--repo needs <owner/repo>");
36
+ flags.repo = repo.toLowerCase();
37
+ }
38
+ else if (name === "kind" && allowed.includes("kind")) {
39
+ const kind = value(arg, args[++i]);
40
+ if (!isMemoryKind(kind))
41
+ throw usage(`--kind is one of ${MEMORY_KINDS.join(", ")}`);
42
+ flags.kind = kind;
43
+ }
44
+ else if (name === "scope" && allowed.includes("scope")) {
45
+ const scope = value(arg, args[++i]);
46
+ if (!isMemoryScope(scope))
47
+ throw usage("--scope is user or repo");
48
+ flags.scope = scope;
49
+ }
50
+ else if (name === "limit" && allowed.includes("limit")) {
51
+ const limit = Number(value(arg, args[++i]));
52
+ if (!Number.isInteger(limit) || limit < 1 || limit > MEMORY_SEARCH_LIMIT_MAX)
53
+ throw usage(`--limit is a whole number from 1 to ${MEMORY_SEARCH_LIMIT_MAX}`);
54
+ flags.limit = limit;
55
+ }
56
+ else if (arg.startsWith("--"))
57
+ throw usage(`unknown argument ${arg}`);
58
+ else
59
+ flags.positional.push(arg);
60
+ }
61
+ return flags;
62
+ }
63
+ export function formatMemoryLine(memory) {
64
+ const where = memory.repo ? `${memory.kind}, ${memory.repo}` : memory.kind;
65
+ return `${memory.id} (${where}) ${memory.text}`;
66
+ }
67
+ export async function cmdMemory(args, deps = {}) {
68
+ const print = deps.print ?? ((line) => console.log(line));
69
+ const api = { listMemories, searchMemories, saveMemory, updateMemory, deleteMemory, getMemoryMode, setMemoryMode, ...deps.api };
70
+ const sub = args[0]?.toLowerCase();
71
+ const rest = args.slice(1);
72
+ if (!sub || sub === "-h" || sub === "--help" || sub === "help" || rest.some((arg) => arg === "-h" || arg === "--help")) {
73
+ print(MEMORY_USAGE);
74
+ return;
75
+ }
76
+ const config = () => (deps.loadConfig ?? loadConfig)();
77
+ const chatId = deps.chatId === undefined ? memoryChatId() : deps.chatId;
78
+ const emit = (json, data, lines) => {
79
+ if (json)
80
+ print(JSON.stringify(data, null, 2));
81
+ else
82
+ for (const line of lines())
83
+ print(line);
84
+ };
85
+ if (sub === "list" || sub === "ls") {
86
+ const flags = parseMemoryFlags(rest, ["repo", "kind", "scope"]);
87
+ if (flags.positional.length > 0)
88
+ throw usage(`unexpected argument ${flags.positional[0]}`);
89
+ const listed = await api.listMemories({ ...(flags.scope ? { scope: flags.scope } : {}), ...(flags.repo ? { repo: flags.repo } : {}), ...(flags.kind ? { kind: flags.kind } : {}) }, await config());
90
+ emit(flags.json, listed, () => [
91
+ `Memory is ${listed.memory_mode}. ${listed.items.length === 1 ? "1 memory" : `${listed.items.length} memories`}.`,
92
+ ...listed.items.map(formatMemoryLine),
93
+ ]);
94
+ return;
95
+ }
96
+ if (sub === "search") {
97
+ const flags = parseMemoryFlags(rest, ["repo", "limit"]);
98
+ const query = flags.positional.join(" ").trim();
99
+ if (!query)
100
+ throw usage("search needs a query");
101
+ const found = await api.searchMemories({ query, ...(flags.repo ? { repo: flags.repo } : {}), ...(flags.limit ? { limit: flags.limit } : {}), chatId }, await config());
102
+ emit(flags.json, found, () => {
103
+ if (found.memory_mode === "off")
104
+ return ["Memory is off, so nothing was searched."];
105
+ const all = [...found.rules, ...found.items];
106
+ return all.length === 0 ? ["Nothing in memory matches."] : all.map(formatMemoryLine);
107
+ });
108
+ return;
109
+ }
110
+ if (sub === "add" || sub === "save" || sub === "remember") {
111
+ const flags = parseMemoryFlags(rest, ["repo", "kind"]);
112
+ const text = flags.positional.join(" ").trim();
113
+ if (!text)
114
+ throw usage("add needs the text to remember");
115
+ if (!flags.kind)
116
+ throw usage(`add needs --kind (${MEMORY_KINDS.join(", ")})`);
117
+ const saved = await api.saveMemory({ text, kind: flags.kind, ...(flags.repo ? { repo: flags.repo } : {}), chatId }, await config());
118
+ emit(flags.json, saved, () => [saved.merged ? `That matched memory ${saved.id}, which now says this.` : `Saved to memory as ${saved.id}.`]);
119
+ return;
120
+ }
121
+ if (sub === "update" || sub === "edit") {
122
+ const flags = parseMemoryFlags(rest, ["kind"]);
123
+ const [id, ...words] = flags.positional;
124
+ const text = words.join(" ").trim();
125
+ if (!id || !text)
126
+ throw usage("update needs a memory id and the new text");
127
+ const updated = await api.updateMemory(id, { text, ...(flags.kind ? { kind: flags.kind } : {}) }, await config());
128
+ emit(flags.json, updated, () => [`Memory ${updated.id} updated.`]);
129
+ return;
130
+ }
131
+ if (sub === "delete" || sub === "rm" || sub === "forget") {
132
+ const flags = parseMemoryFlags(rest, []);
133
+ const [id, extra] = flags.positional;
134
+ if (!id || extra !== undefined)
135
+ throw usage("delete needs one memory id");
136
+ const deleted = await api.deleteMemory(id, await config());
137
+ emit(flags.json, deleted, () => [`Memory ${deleted.id} deleted.`]);
138
+ return;
139
+ }
140
+ if (sub === "mode") {
141
+ const flags = parseMemoryFlags(rest, []);
142
+ const [mode, extra] = flags.positional;
143
+ if (extra !== undefined)
144
+ throw usage(`unexpected argument ${extra}`);
145
+ if (mode !== undefined && !isMemoryMode(mode))
146
+ throw usage("mode is off, on_request or all");
147
+ const result = mode === undefined ? await api.getMemoryMode(await config()) : await api.setMemoryMode(mode, await config());
148
+ emit(flags.json, result, () => [`Memory is ${result.memory_mode}.`]);
149
+ return;
150
+ }
151
+ throw usage(`unknown memory subcommand: ${sub}`);
152
+ }
@@ -8,6 +8,7 @@ import { cmdIssue } from "./issue.js";
8
8
  import { cmdJobs, cmdThread } from "./jobs.js";
9
9
  import { cmdLogin } from "./login.js";
10
10
  import { cmdLogout } from "./logout.js";
11
+ import { cmdMemory } from "./memory.js";
11
12
  import { cmdQueue } from "./queue.js";
12
13
  import { cmdPr } from "./pr.js";
13
14
  import { cmdReview } from "./review.js";
@@ -203,7 +204,8 @@ export const COMMAND_REGISTRY = [
203
204
  " [--auto-land on|off] Auto land default for new stacks",
204
205
  " [--auto-land-settle <seconds>] How long Auto land waits before queueing (15-300, default 60)",
205
206
  " [--merge-queue-batch on|off] Test several queued pull requests on one CI run",
206
- " [--merge-queue-batch-size <n>] Most pull requests per batch (2-8, default 4)",
207
+ " [--merge-queue-batch-size <n>] Most pull requests per batch (2-10, default 4)",
208
+ " [--merge-queue-overdrive on|off] Account default: batch up to 10 queued pull requests; --repo overrides it per repository",
207
209
  " [--ignore-bot add <login>|remove <login>|clear]",
208
210
  " [--vortex-skip-check none|neutral] [--vortex-findings failure|neutral|success]",
209
211
  " [--cyclone-fail-check failure|neutral]",
@@ -217,6 +219,21 @@ export const COMMAND_REGISTRY = [
217
219
  await cmdSettings(args, { mode: ctx.mode });
218
220
  },
219
221
  },
222
+ {
223
+ name: "memory",
224
+ summary: "Your memories: list, search, add, update, delete, mode",
225
+ usage: [
226
+ "mergestorm memory list [--scope user|repo] [--repo <owner/repo>] [--kind <kind>] [--json]",
227
+ "mergestorm memory search <query> [--repo <owner/repo>] [--limit <1-20>] [--json]",
228
+ "mergestorm memory add <text> --kind rule|style|preference|fact|project [--repo <owner/repo>] [--json]",
229
+ "mergestorm memory update <id> <text> [--kind <kind>] [--json]",
230
+ "mergestorm memory delete <id> [--json]",
231
+ "mergestorm memory mode [off|on_request|all] [--json] Read or set whether Breeze remembers",
232
+ ],
233
+ async run(args) {
234
+ await cmdMemory(args);
235
+ },
236
+ },
220
237
  {
221
238
  name: "skill",
222
239
  summary: "Install Mergestorm skills for Claude, Cursor, or agents",
@@ -3,7 +3,7 @@ import { type RepoSettings, type RepoSettingsPatch, type SettingsPatch, type Set
3
3
  * CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
4
4
  * read-only on the API and deliberately have no flag here.
5
5
  */
6
- export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
6
+ export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
7
7
  type IgnoreBotOperation = {
8
8
  action: "add" | "remove";
9
9
  login: string;
@@ -22,6 +22,7 @@ export type SettingsArgs = {
22
22
  repo?: RepoSettingsScope;
23
23
  listRepos?: boolean;
24
24
  };
25
+ export declare function parsePathsFlagValue(flag: string, value: string | undefined, defaults: readonly string[]): string[];
25
26
  export declare function formatRepoSettingsLines(repos: readonly RepoSettings[]): string[];
26
27
  /** Parse boolean and enum flags plus ordered ignore-list operations. */
27
28
  export declare function parseSettingsArgs(rawArgs: string[]): SettingsArgs;
@@ -14,7 +14,25 @@ const REPO_FLAG_LABELS = {
14
14
  auto_patch_enabled: "Auto patch",
15
15
  auto_review_enabled: "Auto review",
16
16
  auto_land_default: "Auto land (new stacks)",
17
+ merge_queue_batch_enabled: "Merge queue batching",
18
+ merge_queue_batch_size: "Merge queue batch size (pull requests)",
19
+ merge_queue_lands_alone_paths: "Merge queue lands-alone paths",
20
+ merge_queue_overdrive: "Merge queue overdrive",
17
21
  };
22
+ export function parsePathsFlagValue(flag, value, defaults) {
23
+ if (value === undefined || value === "" || value.startsWith("--")) {
24
+ throw new CommandError(`${flag} takes a comma-separated list of path patterns, default, or none.`, 2, "usage");
25
+ }
26
+ if (value === "default")
27
+ return [...defaults];
28
+ if (value === "none")
29
+ return [];
30
+ const paths = [...new Set(value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== ""))];
31
+ if (paths.length === 0 || paths.some((entry) => /\s/.test(entry))) {
32
+ throw new CommandError(`${flag} takes a comma-separated list of path patterns, default, or none.`, 2, "usage");
33
+ }
34
+ return paths;
35
+ }
18
36
  function parseRepoSlug(raw) {
19
37
  const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(raw ?? "");
20
38
  if (!match)
@@ -61,18 +79,41 @@ function parseRepoSettingsArgs(slug, args) {
61
79
  throw new CommandError(`Unknown settings flag: ${flag}. See \`mergestorm --help\`.`, 2, "usage");
62
80
  }
63
81
  if (!REPO_SETTINGS_KEYS.includes(key)) {
64
- throw new CommandError(`${flag} is an account setting. With --repo only --auto-patch, --auto-review and --auto-land can be set.`, 2, "usage");
82
+ throw new CommandError(`${flag} is an account setting. With --repo only --auto-patch, --auto-review, --auto-land, --merge-queue-batch, --merge-queue-batch-size, --merge-queue-lands-alone and --merge-queue-overdrive can be set.`, 2, "usage");
65
83
  }
66
84
  const value = eq >= 0 ? arg.slice(eq + 1) : args[++i];
67
- if (value !== "on" && value !== "off" && value !== "inherit") {
85
+ const row = BEARER_SETTINGS.find((row) => row.key === key);
86
+ if (value === "inherit") {
87
+ scope.patch[key] = null;
88
+ continue;
89
+ }
90
+ if ("kind" in row && row.kind === "count") {
91
+ const amount = value !== undefined && /^\d+$/.test(value) ? Number(value) : Number.NaN;
92
+ if (!Number.isInteger(amount) || amount < row.min || amount > row.max) {
93
+ throw new CommandError(`${flag} takes a whole number from ${row.min} through ${row.max}, or inherit, with --repo.`, 2, "usage");
94
+ }
95
+ scope.patch.merge_queue_batch_size = amount;
96
+ continue;
97
+ }
98
+ if ("kind" in row && row.kind === "paths") {
99
+ scope.patch.merge_queue_lands_alone_paths = parsePathsFlagValue(flag, value, row.defaults);
100
+ continue;
101
+ }
102
+ if (value !== "on" && value !== "off") {
68
103
  throw new CommandError(`${flag} takes on, off or inherit with --repo.`, 2, "usage");
69
104
  }
70
- scope.patch[key] = value === "inherit" ? null : value === "on";
105
+ scope.patch[key] = value === "on";
71
106
  }
72
107
  return { json, patch: {}, repo: scope };
73
108
  }
74
109
  function repoSettingWords(value) {
75
- return value === null ? "inherit (follows the account)" : value ? "on" : "off";
110
+ if (value === null)
111
+ return "inherit (follows the account)";
112
+ if (Array.isArray(value))
113
+ return value.length === 0 ? "(none)" : value.join(", ");
114
+ if (typeof value === "number")
115
+ return String(value);
116
+ return value ? "on" : "off";
76
117
  }
77
118
  export function formatRepoSettingsLines(repos) {
78
119
  if (repos.length === 0)
@@ -149,6 +190,10 @@ export function parseSettingsArgs(rawArgs) {
149
190
  }
150
191
  const value = eq >= 0 ? arg.slice(eq + 1) : args[++i];
151
192
  const row = BEARER_SETTINGS.find((row) => row.key === key);
193
+ if ("kind" in row && row.kind === "paths") {
194
+ Object.assign(patch, { [key]: parsePathsFlagValue(flag, value, row.defaults) });
195
+ continue;
196
+ }
152
197
  if ("kind" in row && row.kind === "logins") {
153
198
  if (value === "clear")
154
199
  ignoreBots.push({ action: "clear" });
@@ -180,6 +180,7 @@ export declare function cmdStackWatch(args: string[], deps?: {
180
180
  loadConfig?: typeof loadConfig;
181
181
  runStackWatch?: typeof runStackWatch;
182
182
  signal?: AbortSignal;
183
+ writeMarker?: (line: string) => void;
183
184
  }): Promise<void>;
184
185
  export declare function cmdStackStatus(args: string[], deps?: {
185
186
  loadConfig?: typeof loadConfig;
@@ -11,7 +11,7 @@ import { publishBranchServerSide, assertExplicitStacking, publishWorkingBranch,
11
11
  import { gitTopLevel } from "../repo-identity.js";
12
12
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
13
13
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
14
- import { runStackWatch, STACK_WATCH_EXIT } from "../stack-watch-loop.js";
14
+ import { runStackWatch, STACK_WATCH_EXIT, stackWatchFailedLine } from "../stack-watch-loop.js";
15
15
  import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
16
16
  import { ansi } from "../ui/ansi.js";
17
17
  import { runLineTabsBrowser } from "../ui/line-tabs.js";
@@ -62,7 +62,8 @@ const STACK_USAGE = `usage:
62
62
  mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]
63
63
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
64
64
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
65
- error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
65
+ error twice, or 15 minutes with no stack read: timeouts, 5xx and rate limits, which it retries with backoff).
66
+ It also prints MS-WATCH ATTENTION failed when it cannot start (bad flags, no config), so it never exits without an MS-WATCH line unless you stop it; prints MS-WATCH LANDED
66
67
  and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
67
68
  An ATTENTION line for a merge queue bounce ends with restart="mg stack watch <id> --bounce-id <id> --after-finished-at <iso>":
68
69
  restart with that command (plus --head <sha> after a push) so a bounce you already handled is not reported again.
@@ -1213,7 +1214,7 @@ export async function cmdStackWait(args, deps = {}) {
1213
1214
  throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
1214
1215
  }
1215
1216
  }
1216
- export async function cmdStackWatch(args, deps = {}) {
1217
+ async function startStackWatch(args, deps) {
1217
1218
  const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]";
1218
1219
  const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
1219
1220
  const positional = [];
@@ -1286,6 +1287,30 @@ export async function cmdStackWatch(args, deps = {}) {
1286
1287
  ...(afterFinishedAt ? { afterFinishedAt } : {}),
1287
1288
  } } : {}),
1288
1289
  });
1290
+ return { stackId, result };
1291
+ }
1292
+ export async function cmdStackWatch(args, deps = {}) {
1293
+ let started;
1294
+ try {
1295
+ started = await startStackWatch(args, deps);
1296
+ }
1297
+ catch (err) {
1298
+ const valueFlags = new Set(["--until", "--ignore", "--max", "--head", "--bounce-id", "--after-finished-at"]);
1299
+ let json = false;
1300
+ for (let i = 0; i < args.length; i++) {
1301
+ if (args[i] === "--json")
1302
+ json = true;
1303
+ else if (valueFlags.has(args[i]))
1304
+ i++;
1305
+ }
1306
+ const marker = deps.writeMarker ?? (json ? console.error : console.log);
1307
+ const stackId = args.find((arg, i) => UUID_RE.test(arg.trim()) && !valueFlags.has(args[i - 1]))?.trim() ?? "none";
1308
+ marker(stackWatchFailedLine(stackId, err instanceof Error ? err.message : String(err)));
1309
+ if (json)
1310
+ console.log(JSON.stringify({ stackId, outcome: "failed" }));
1311
+ throw err;
1312
+ }
1313
+ const { stackId, result } = started;
1289
1314
  if (result.exitCode === STACK_WATCH_EXIT.landed)
1290
1315
  return;
1291
1316
  const reason = result.outcome === "timeout"
package/dist/config.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare function loadConfig(): Promise<Config>;
10
10
  export declare function saveConfig(cfg: Config): Promise<void>;
11
11
  export declare function clearConfig(): Promise<void>;
12
12
  export declare function apiBase(cfg: Config): string;
13
+ export declare function resolveChatId(cfg: Config): string | undefined;
13
14
  export declare function resolveApiKey(cfg: Config): string | undefined;
14
15
  /** Short display form of a stored key (prefix only). */
15
16
  export declare function keyDisplay(key: string | undefined): string | null;
package/dist/config.js CHANGED
@@ -33,6 +33,13 @@ export function apiBase(cfg) {
33
33
  const fromEnv = cfg.ignoreEnv ? undefined : process.env.MERGESTORM_API_URL?.trim();
34
34
  return (fromEnv || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
35
35
  }
36
+ const CHAT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
37
+ export function resolveChatId(cfg) {
38
+ if (cfg.ignoreEnv)
39
+ return undefined;
40
+ const chatId = process.env.MERGESTORM_CHAT_ID?.trim() ?? "";
41
+ return CHAT_ID.test(chatId) ? chatId.toLowerCase() : undefined;
42
+ }
36
43
  export function resolveApiKey(cfg) {
37
44
  if (cfg.ignoreEnv)
38
45
  return cfg.apiKey;
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "git_failed" | "unknown_label" | "invalid_input" | "memory_off" | "memory_full" | "memory_unavailable";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;
@@ -0,0 +1,73 @@
1
+ import { type Config } from "./config.js";
2
+ export declare const MEMORY_MODES: readonly ["off", "on_request", "all"];
3
+ export declare const MEMORY_SCOPES: readonly ["user", "repo"];
4
+ export declare const MEMORY_KINDS: readonly ["rule", "style", "preference", "fact", "project"];
5
+ export declare const MEMORY_SEARCH_LIMIT_MAX = 20;
6
+ export type MemoryMode = (typeof MEMORY_MODES)[number];
7
+ export type MemoryScope = (typeof MEMORY_SCOPES)[number];
8
+ export type MemoryKind = (typeof MEMORY_KINDS)[number];
9
+ export type MemoryItem = {
10
+ id: string;
11
+ scope: MemoryScope;
12
+ repo: string | null;
13
+ kind: MemoryKind;
14
+ text: string;
15
+ source: string | null;
16
+ source_url: string | null;
17
+ created_at: string;
18
+ updated_at: string;
19
+ };
20
+ export type MemoryList = {
21
+ memory_mode: MemoryMode;
22
+ items: MemoryItem[];
23
+ };
24
+ export type MemorySearch = {
25
+ memory_mode: MemoryMode;
26
+ rules: MemoryItem[];
27
+ items: MemoryItem[];
28
+ total: number;
29
+ };
30
+ export type MemorySaved = {
31
+ id: string;
32
+ merged: boolean;
33
+ };
34
+ export type MemoryUpdated = {
35
+ id: string;
36
+ text: string;
37
+ kind?: MemoryKind;
38
+ };
39
+ export declare function memoryChatId(value?: string | null | undefined): string | null;
40
+ export declare function isMemoryMode(value: unknown): value is MemoryMode;
41
+ export declare function isMemoryKind(value: unknown): value is MemoryKind;
42
+ export declare function isMemoryScope(value: unknown): value is MemoryScope;
43
+ export declare function listMemories(filter?: {
44
+ scope?: MemoryScope;
45
+ repo?: string;
46
+ kind?: MemoryKind;
47
+ }, cfg?: Config): Promise<MemoryList>;
48
+ export declare function searchMemories(input: {
49
+ query: string;
50
+ repo?: string;
51
+ limit?: number;
52
+ chatId?: string | null;
53
+ }, cfg?: Config): Promise<MemorySearch>;
54
+ export declare function saveMemory(input: {
55
+ text: string;
56
+ kind: MemoryKind;
57
+ repo?: string | null;
58
+ chatId?: string | null;
59
+ }, cfg?: Config): Promise<MemorySaved>;
60
+ export declare function updateMemory(id: string, change: {
61
+ text: string;
62
+ kind?: MemoryKind;
63
+ }, cfg?: Config): Promise<MemoryUpdated>;
64
+ export declare function deleteMemory(id: string, cfg?: Config): Promise<{
65
+ id: string;
66
+ deleted: true;
67
+ }>;
68
+ export declare function getMemoryMode(cfg?: Config): Promise<{
69
+ memory_mode: MemoryMode;
70
+ }>;
71
+ export declare function setMemoryMode(mode: MemoryMode, cfg?: Config): Promise<{
72
+ memory_mode: MemoryMode;
73
+ }>;
@@ -0,0 +1,69 @@
1
+ import { apiFetch } from "./api.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { CommandError, REVIEW_EXIT, rateLimitedMessage } from "./errors.js";
4
+ export const MEMORY_MODES = ["off", "on_request", "all"];
5
+ export const MEMORY_SCOPES = ["user", "repo"];
6
+ export const MEMORY_KINDS = ["rule", "style", "preference", "fact", "project"];
7
+ export const MEMORY_SEARCH_LIMIT_MAX = 20;
8
+ const CHAT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export function memoryChatId(value = process.env.MERGESTORM_CHAT_ID) {
10
+ const trimmed = value?.trim() ?? "";
11
+ return CHAT_ID.test(trimmed) ? trimmed.toLowerCase() : null;
12
+ }
13
+ export function isMemoryMode(value) {
14
+ return typeof value === "string" && MEMORY_MODES.includes(value);
15
+ }
16
+ export function isMemoryKind(value) {
17
+ return typeof value === "string" && MEMORY_KINDS.includes(value);
18
+ }
19
+ export function isMemoryScope(value) {
20
+ return typeof value === "string" && MEMORY_SCOPES.includes(value);
21
+ }
22
+ const KNOWN_CODES = new Set(["invalid_input", "not_found", "memory_off", "memory_full", "memory_unavailable", "payload_too_large"]);
23
+ function memoryFailure(status, body, retryAfterSeconds, action) {
24
+ const row = (body && typeof body === "object" ? body : {});
25
+ if (status === 429 || row.error === "rate_limited") {
26
+ return new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined);
27
+ }
28
+ const message = typeof row.message === "string" && row.message ? row.message : null;
29
+ const code = typeof row.error === "string" && KNOWN_CODES.has(row.error) ? row.error : undefined;
30
+ if (status === 404 && !code)
31
+ return new CommandError("This Mergestorm server does not manage memories yet.", 1, "not_found");
32
+ return new CommandError(message ?? `${action} (HTTP ${status})`, 1, code);
33
+ }
34
+ async function call(cfg, route, init, ok, action) {
35
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg ?? (await loadConfig()), route, init);
36
+ if (!ok.includes(status))
37
+ throw memoryFailure(status, body, retryAfterSeconds, action);
38
+ return body;
39
+ }
40
+ function query(params) {
41
+ const search = new URLSearchParams();
42
+ for (const [key, value] of Object.entries(params))
43
+ if (value !== undefined && value !== null && value !== "")
44
+ search.set(key, String(value));
45
+ const text = search.toString();
46
+ return text ? `?${text}` : "";
47
+ }
48
+ export function listMemories(filter = {}, cfg) {
49
+ return call(cfg, `/api/v1/memories${query(filter)}`, {}, [200], "Could not list memories");
50
+ }
51
+ export function searchMemories(input, cfg) {
52
+ return call(cfg, `/api/v1/memories/search${query({ query: input.query, repo: input.repo, limit: input.limit, chat_id: input.chatId })}`, {}, [200], "Could not search memories");
53
+ }
54
+ export function saveMemory(input, cfg) {
55
+ const json = { text: input.text, kind: input.kind, ...(input.repo ? { repo: input.repo } : {}), ...(input.chatId ? { chat_id: input.chatId } : {}) };
56
+ return call(cfg, "/api/v1/memories", { method: "POST", json }, [200, 201], "Could not save the memory");
57
+ }
58
+ export function updateMemory(id, change, cfg) {
59
+ return call(cfg, `/api/v1/memories/${encodeURIComponent(id)}`, { method: "PATCH", json: change }, [200], "Could not update the memory");
60
+ }
61
+ export function deleteMemory(id, cfg) {
62
+ return call(cfg, `/api/v1/memories/${encodeURIComponent(id)}`, { method: "DELETE" }, [200], "Could not delete the memory");
63
+ }
64
+ export function getMemoryMode(cfg) {
65
+ return call(cfg, "/api/v1/memories/mode", {}, [200], "Could not read the memory mode");
66
+ }
67
+ export function setMemoryMode(mode, cfg) {
68
+ return call(cfg, "/api/v1/memories/mode", { method: "PUT", json: { memory_mode: mode } }, [200], "Could not set the memory mode");
69
+ }
@@ -8,26 +8,28 @@ export declare const SERVER_PUBLISH_MAX_TOTAL_BYTES: number;
8
8
  export declare const SERVER_PUBLISH_MAX_COMMIT_BYTES: number;
9
9
  export declare const SERVER_PUBLISH_FILE_MODES: readonly ["100644", "100755", "120000"];
10
10
  export declare const SERVER_PUBLISH_HISTORY_SCAN = 500;
11
- export type GitRead = (args: string[], cwd: string) => Buffer;
11
+ export type GitRead = (args: string[], cwd: string) => Buffer | Promise<Buffer>;
12
+ export declare const READ_ONLY_GIT: Set<string>;
12
13
  export declare function readGit(args: string[], cwd: string): Buffer;
13
- export declare function readCommitHeader(read: GitRead, sha: string, cwd: string): {
14
+ export declare function readCommitHeader(read: GitRead, sha: string, cwd: string): Promise<{
14
15
  tree: string;
15
16
  parents: string[];
16
17
  author: StackPublicationIdentity;
17
18
  committer: StackPublicationIdentity;
18
19
  message: string;
19
- };
20
+ }>;
20
21
  export declare function readCommitChanges(read: GitRead, parent: string, sha: string, cwd: string, budget: {
21
22
  changes: number;
22
23
  bytes: number;
23
- }): StackPublicationChange[];
24
- export declare function readPublicationCommits(read: GitRead, from: string, to: string, cwd: string): StackPublicationCommit[];
24
+ }): Promise<StackPublicationChange[]>;
25
+ export declare function readPublicationCommits(read: GitRead, from: string, to: string, cwd: string): Promise<StackPublicationCommit[]>;
25
26
  export type ServerPublishInput = {
26
27
  cfg: Config;
27
28
  cwd: string;
28
29
  owner: string;
29
30
  repo: string;
30
31
  branch: string;
32
+ localRef?: string;
31
33
  baseBranch: string;
32
34
  localBaseRef?: string;
33
35
  remoteBaseSha?: string;
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { getStackPublication, submitStackPublication, } from "./api.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { CommandError } from "./errors.js";
4
+ import { CommandError, isCommandErrorCode } from "./errors.js";
5
5
  import { parseGithubOriginRepo } from "./git.js";
6
6
  import { currentBranch, discoverTrunk, tipCommitMessage, tipCommitSubject } from "./git-stack.js";
7
7
  import { buildPrBodyFromCommit } from "./pr-body.js";
@@ -12,26 +12,30 @@ export const SERVER_PUBLISH_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
12
12
  export const SERVER_PUBLISH_MAX_COMMIT_BYTES = 1024 * 1024;
13
13
  export const SERVER_PUBLISH_FILE_MODES = ["100644", "100755", "120000"];
14
14
  export const SERVER_PUBLISH_HISTORY_SCAN = 500;
15
- const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
15
+ export const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
16
16
  export function readGit(args, cwd) {
17
17
  if (!READ_ONLY_GIT.has(args[0] ?? "")) {
18
18
  throw new Error(`server-side publication only reads the repository; refusing git ${args[0] ?? ""}`);
19
19
  }
20
20
  const result = spawnSync("git", args, { cwd, maxBuffer: 256 * 1024 * 1024 });
21
+ if (result.error)
22
+ throw result.error;
21
23
  if (result.status !== 0) {
22
- throw new Error(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`);
24
+ throw new CommandError(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`, 1, "git_failed");
23
25
  }
24
26
  return result.stdout;
25
27
  }
26
- function text(read, args, cwd) {
27
- return read(args, cwd).toString("utf8").trim();
28
+ async function text(read, args, cwd) {
29
+ return (await read(args, cwd)).toString("utf8").trim();
28
30
  }
29
- function resolves(read, ref, cwd) {
31
+ async function resolves(read, ref, cwd) {
30
32
  try {
31
- return text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
33
+ return await text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
32
34
  }
33
- catch {
34
- return null;
35
+ catch (err) {
36
+ if (isCommandErrorCode(err, "git_failed"))
37
+ return null;
38
+ throw err;
35
39
  }
36
40
  }
37
41
  function isoWithOffset(epochSeconds, offset) {
@@ -52,12 +56,12 @@ function parseIdentity(line, sha) {
52
56
  }
53
57
  return { name: match[1].trim() ? match[1] : match[2], email: match[2], date };
54
58
  }
55
- export function readCommitHeader(read, sha, cwd) {
56
- const commitBytes = Number(text(read, ["cat-file", "-s", sha], cwd));
59
+ export async function readCommitHeader(read, sha, cwd) {
60
+ const commitBytes = Number(await text(read, ["cat-file", "-s", sha], cwd));
57
61
  if (commitBytes > SERVER_PUBLISH_MAX_COMMIT_BYTES) {
58
62
  throw new CommandError(`Commit ${sha} is ${commitBytes} bytes; server-side publication reads commits up to ${SERVER_PUBLISH_MAX_COMMIT_BYTES} bytes.`, 1, "payload_too_large");
59
63
  }
60
- const commitObject = read(["cat-file", "commit", sha], cwd);
64
+ const commitObject = await read(["cat-file", "commit", sha], cwd);
61
65
  let raw;
62
66
  try {
63
67
  raw = new TextDecoder("utf-8", { fatal: true }).decode(commitObject);
@@ -87,8 +91,8 @@ export function readCommitHeader(read, sha, cwd) {
87
91
  }
88
92
  return { tree, parents, author: parseIdentity(author, sha), committer: parseIdentity(committer, sha), message };
89
93
  }
90
- export function readCommitChanges(read, parent, sha, cwd, budget) {
91
- const listing = read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
94
+ export async function readCommitChanges(read, parent, sha, cwd, budget) {
95
+ const listing = await read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
92
96
  let raw;
93
97
  try {
94
98
  raw = new TextDecoder("utf-8", { fatal: true }).decode(listing);
@@ -114,7 +118,7 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
114
118
  throw new CommandError(`${path} has Git mode ${newMode}, which server-side publication does not support` +
115
119
  `${newMode === "160000" ? " (it is a submodule)" : ""}. Supported modes are ${SERVER_PUBLISH_FILE_MODES.join(", ")}.`, 1, "unsupported_publication");
116
120
  }
117
- const size = Number(text(read, ["cat-file", "-s", newSha], cwd));
121
+ const size = Number(await text(read, ["cat-file", "-s", newSha], cwd));
118
122
  if (size > SERVER_PUBLISH_MAX_FILE_BYTES) {
119
123
  throw new CommandError(`${path} is ${size} bytes; server-side publication carries files up to ${SERVER_PUBLISH_MAX_FILE_BYTES} bytes.`, 1, "payload_too_large");
120
124
  }
@@ -122,30 +126,32 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
122
126
  if (budget.bytes > SERVER_PUBLISH_MAX_TOTAL_BYTES) {
123
127
  throw new CommandError(`More than ${SERVER_PUBLISH_MAX_TOTAL_BYTES} bytes of file content; server-side publication cannot carry this branch in one request.`, 1, "payload_too_large");
124
128
  }
125
- const content = read(["cat-file", "blob", newSha], cwd);
129
+ const content = await read(["cat-file", "blob", newSha], cwd);
126
130
  changes.push({ path, mode: newMode, sha: newSha, content: content.toString("base64") });
127
131
  }
128
132
  return changes;
129
133
  }
130
- export function readPublicationCommits(read, from, to, cwd) {
134
+ export async function readPublicationCommits(read, from, to, cwd) {
131
135
  try {
132
- read(["merge-base", "--is-ancestor", from, to], cwd);
136
+ await read(["merge-base", "--is-ancestor", from, to], cwd);
133
137
  }
134
- catch {
138
+ catch (err) {
139
+ if (!isCommandErrorCode(err, "git_failed"))
140
+ throw err;
135
141
  throw new CommandError(`${from} is not an ancestor of ${to}; the commits to publish are not a straight line.`, 1, "unsupported_publication");
136
142
  }
137
- if (text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
143
+ if (await text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
138
144
  throw new CommandError(`A merge commit sits between ${from} and ${to}. Server-side publication carries single-parent commits only; rebase or squash the branch.`, 1, "unsupported_publication");
139
145
  }
140
- const shas = text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd).split("\n").filter(Boolean);
146
+ const shas = (await text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd)).split("\n").filter(Boolean);
141
147
  if (shas.length > SERVER_PUBLISH_MAX_COMMITS) {
142
148
  throw new CommandError(`${shas.length} commits to publish; server-side publication carries at most ${SERVER_PUBLISH_MAX_COMMITS} at once. Squash the branch.`, 1, "payload_too_large");
143
149
  }
144
150
  const commits = [];
145
151
  const budget = { changes: 0, bytes: 0 };
146
152
  for (const sha of shas) {
147
- const header = readCommitHeader(read, sha, cwd);
148
- const changes = readCommitChanges(read, header.parents[0], sha, cwd, budget);
153
+ const header = await readCommitHeader(read, sha, cwd);
154
+ const changes = await readCommitChanges(read, header.parents[0], sha, cwd, budget);
149
155
  commits.push({
150
156
  message: header.message,
151
157
  author: header.author,
@@ -156,8 +162,8 @@ export function readPublicationCommits(read, from, to, cwd) {
156
162
  }
157
163
  return commits;
158
164
  }
159
- function localCommitWithTree(read, from, branch, head, cwd) {
160
- const lines = text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd)
165
+ async function localCommitWithTree(read, from, branch, head, cwd) {
166
+ const lines = (await text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd))
161
167
  .split("\n")
162
168
  .filter(Boolean);
163
169
  let match = null;
@@ -176,22 +182,23 @@ export async function publishBranchServerSide(input, deps = {}) {
176
182
  const status = deps.getStackPublication ?? getStackPublication;
177
183
  const submit = deps.submitStackPublication ?? submitStackPublication;
178
184
  const { cfg, cwd, owner, repo, branch } = input;
179
- if (!resolves(read, branch, cwd))
180
- throw new CommandError(`Branch missing locally: ${branch}`);
185
+ const local = input.localRef ?? branch;
186
+ if (!(await resolves(read, local, cwd)))
187
+ throw new CommandError(`Branch missing locally: ${local}`);
181
188
  const remote = await status(cfg, { owner, repo, branch });
182
189
  const baseBranch = remote.pr?.base ?? input.baseBranch;
183
190
  deps.assertBase?.(baseBranch);
184
191
  const localBase = input.localBaseRef ??
185
- (resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
186
- if (!resolves(read, localBase, cwd))
192
+ ((await resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd)) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
193
+ if (!(await resolves(read, localBase, cwd)))
187
194
  throw new CommandError(`Base branch missing locally: ${localBase}`);
188
- const fork = text(read, ["merge-base", localBase, branch], cwd);
195
+ const fork = await text(read, ["merge-base", localBase, local], cwd);
189
196
  const baseSha = input.remoteBaseSha ?? fork;
190
197
  let onto = "base";
191
198
  let expectedHead = null;
192
199
  let from = fork;
193
200
  if (remote.head) {
194
- const published = remote.head.tree ? localCommitWithTree(read, fork, branch, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
201
+ const published = remote.head.tree ? await localCommitWithTree(read, fork, local, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
195
202
  if (published) {
196
203
  onto = "head";
197
204
  expectedHead = remote.head.sha;
@@ -209,7 +216,7 @@ export async function publishBranchServerSide(input, deps = {}) {
209
216
  `To replace that head with your local branch, pass --replace-head ${remote.head.sha}.`, 1, "head_changed");
210
217
  }
211
218
  }
212
- const commits = readPublicationCommits(read, from, branch, cwd);
219
+ const commits = await readPublicationCommits(read, from, local, cwd);
213
220
  if (commits.length === 0 && onto === "base") {
214
221
  throw new CommandError(`No commits on \`${branch}\` ahead of \`${input.baseBranch}\`. Commit your changes, then retry.`);
215
222
  }
@@ -224,7 +231,7 @@ export async function publishBranchServerSide(input, deps = {}) {
224
231
  pr: { title: input.title, body: input.body },
225
232
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
226
233
  });
227
- const localTree = text(read, ["rev-parse", `${branch}^{tree}`], cwd);
234
+ const localTree = await text(read, ["rev-parse", `${local}^{tree}`], cwd);
228
235
  if (publication.head.tree !== localTree) {
229
236
  throw new CommandError(`GitHub has tree ${publication.head.tree} at ${branch}, but your local branch has tree ${localTree}.`, 1, "tree_mismatch");
230
237
  }
@@ -43,6 +43,7 @@ export type StackRepairHint = {
43
43
  branch: string;
44
44
  liveParent: string | null;
45
45
  files: string[];
46
+ publishedBy?: "breeze";
46
47
  steps: string;
47
48
  note?: string;
48
49
  } | {
@@ -74,6 +75,7 @@ export type StackRepairHint = {
74
75
  branch: string;
75
76
  liveParent: string | null;
76
77
  restackKind: string;
78
+ publishedBy?: "breeze";
77
79
  steps: string;
78
80
  } | {
79
81
  kind: "seam_review_stuck";
@@ -213,10 +215,13 @@ export type MergeQueueWait = {
213
215
  export declare function mergeQueueWait(queue: readonly MergeQueueEntryDto[], nowMs: number): MergeQueueWait | null;
214
216
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
215
217
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
218
+ export declare const BREEZE_BRANCH_PREFIX = "breeze/";
219
+ export declare function isBreezeBranch(branch: string | null | undefined): boolean;
220
+ export declare function breezeConflictRepairSteps(branch: string, liveParent: string | null, prNumber: number): string;
216
221
  export declare function restackFailedRepairSteps(branch: string, liveParent: string | null, failure: {
217
222
  kind: string;
218
223
  detail?: string | null;
219
- }): string;
224
+ }, prNumber?: number): string;
220
225
  export declare function agentsCannotClear(reason: StackBlockerReason, stack?: StackDto, layer?: StackLayerDto): boolean;
221
226
  export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null, nowMs?: number): StackRepairHint | null;
222
227
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
@@ -679,11 +679,22 @@ export function conflictRepairSteps(branch, liveParent) {
679
679
  }
680
680
  return `Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve the conflicts, run the tests, then an ordinary git push (no force). Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
681
681
  }
682
- export function restackFailedRepairSteps(branch, liveParent, failure) {
682
+ export const BREEZE_BRANCH_PREFIX = "breeze/";
683
+ export function isBreezeBranch(branch) {
684
+ return typeof branch === "string" && branch.startsWith(BREEZE_BRANCH_PREFIX);
685
+ }
686
+ export function breezeConflictRepairSteps(branch, liveParent, prNumber) {
687
+ if (!liveParent)
688
+ return conflictRepairSteps(branch, liveParent);
689
+ return `${branch} is a Breeze branch: Breeze updates it only with open_pull_request, which publishes single-parent commits, so it cannot publish a merge of ${liveParent}. Rebase it in the Breeze chat that opened #${prNumber}: fetch ${liveParent} with fetch_repository, git rebase origin/${liveParent}, resolve the conflicts, run the tests, then call open_pull_request again. Or have Cyclone resolve it: cyclone_start with task resolve_conflicts and pr_number ${prNumber}. Do not retarget the PR base.`;
690
+ }
691
+ export function restackFailedRepairSteps(branch, liveParent, failure, prNumber) {
683
692
  const why = `Mergestorm could not restack ${branch}${liveParent ? ` onto ${liveParent}` : ""} (${failure.kind}${failure.detail?.trim() ? `: ${failure.detail.trim()}` : ""}) and will not try this head again.`;
684
693
  if (!liveParent) {
685
694
  return `${why} The live parent of ${branch} is unknown (it is never an mg-park-* freeze). Do not merge or rebase anything; stop and tell the human.`;
686
695
  }
696
+ if (prNumber !== undefined && isBreezeBranch(branch))
697
+ return `${why} ${breezeConflictRepairSteps(branch, liveParent, prNumber)}`;
687
698
  return `${why} Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve any conflicts, run the tests, then an ordinary git push (no force). Mergestorm clears Restack failed once the pushed head contains the tip of ${liveParent}. If GitHub refuses the push (branch protection or permissions), stop and tell the human. Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
688
699
  }
689
700
  function landPrLiveParent(stack, layer) {
@@ -725,6 +736,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
725
736
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
726
737
  const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
727
738
  const mergeConflict = reason === "merge_conflict";
739
+ const breeze = !landPr && isBreezeBranch(layer.branch);
728
740
  if (restackConflict || mergeConflict) {
729
741
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
730
742
  const note = conflictHandoffNote(stack, layer);
@@ -735,7 +747,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
735
747
  branch: layer.branch,
736
748
  liveParent,
737
749
  files: conflictFiles(layer, bounce),
738
- steps: conflictRepairSteps(layer.branch, liveParent),
750
+ ...(breeze ? { publishedBy: "breeze" } : {}),
751
+ steps: breeze ? breezeConflictRepairSteps(layer.branch, liveParent, layer.prNumber) : conflictRepairSteps(layer.branch, liveParent),
739
752
  ...(note ? { note } : {}),
740
753
  };
741
754
  }
@@ -748,7 +761,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
748
761
  branch: layer.branch,
749
762
  liveParent,
750
763
  restackKind: layer.restackError.kind,
751
- steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
764
+ ...(breeze ? { publishedBy: "breeze" } : {}),
765
+ steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError, layer.prNumber),
752
766
  };
753
767
  }
754
768
  if (reason === "unit_abandoned") {
@@ -497,6 +497,7 @@ export type MergeQueueEntryDto = {
497
497
  verifyBaseSha: string | null;
498
498
  cancelRequestedAt?: string | null;
499
499
  batch?: MergeQueueEntryBatchDto | null;
500
+ ci?: MergeQueueBatchCiDto | null;
500
501
  finishedAt: string | null;
501
502
  claimedAt?: string | null;
502
503
  aheadInRepo?: MergeQueueAheadDto | null;
@@ -171,9 +171,17 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
171
171
  await pause(STACK_WATCH_FAILED_RETRY_MS);
172
172
  return null;
173
173
  };
174
+ const unreadTooLong = (message, envelope, since) => {
175
+ transientSince ??= since;
176
+ if (now() - transientSince < STACK_WATCH_TRANSIENT_WINDOW_MS)
177
+ return null;
178
+ emit(stackWatchFailedLine(id, `No stack read for 15 minutes: ${message}`), envelope, "failed");
179
+ return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
180
+ };
174
181
  try {
175
182
  while (now() < deadline) {
176
183
  let envelope;
184
+ const sliceStartedAt = now();
177
185
  try {
178
186
  envelope = await poll(cfg, id, {
179
187
  timeoutMs: Math.min(STACK_WATCH_SLICE_MS, Math.max(0, deadline - now())),
@@ -189,8 +197,13 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
189
197
  cursor = err.lastEnvelope.cursor;
190
198
  failures = 0;
191
199
  permanentFailures = 0;
192
- if (err.lastEnvelope.assessment === "available")
200
+ if (err.lastEnvelope.status !== "failed")
193
201
  transientSince = null;
202
+ else {
203
+ const unread = unreadTooLong("No stack snapshot was read in a whole watch slice", err.lastEnvelope, sliceStartedAt);
204
+ if (unread)
205
+ return unread;
206
+ }
194
207
  continue;
195
208
  }
196
209
  if (err instanceof StackWatchError && err.lastEnvelope.watch.done) {
@@ -198,7 +211,15 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
198
211
  return await finished(err.lastEnvelope);
199
212
  }
200
213
  if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
201
- await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
214
+ if (err.readSnapshot)
215
+ transientSince = now();
216
+ else {
217
+ const unread = unreadTooLong(err.message, err.lastEnvelope, sliceStartedAt);
218
+ if (unread)
219
+ return unread;
220
+ }
221
+ const retryAfterMs = err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS;
222
+ await pause(Math.min(retryAfterMs, Math.max(1_000, transientSince + STACK_WATCH_TRANSIENT_WINDOW_MS - now())));
202
223
  continue;
203
224
  }
204
225
  const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null, isTransientWatchFailure(err));
@@ -58,7 +58,8 @@ export declare class StackWatchTimeoutError extends Error {
58
58
  export declare class StackWatchError extends Error {
59
59
  readonly lastEnvelope: StackWatchEnvelope;
60
60
  readonly retryAfterSeconds?: number | undefined;
61
- constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined);
61
+ readonly readSnapshot: boolean;
62
+ constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined, readSnapshot?: boolean);
62
63
  }
63
64
  /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
64
65
  export declare function pollStackWatch(cfg: Config, stackId: string, opts?: PollStackWatchOptions): Promise<StackWatchEnvelope>;
@@ -20,10 +20,12 @@ export class StackWatchTimeoutError extends Error {
20
20
  export class StackWatchError extends Error {
21
21
  lastEnvelope;
22
22
  retryAfterSeconds;
23
- constructor(message, lastEnvelope, options, retryAfterSeconds) {
23
+ readSnapshot;
24
+ constructor(message, lastEnvelope, options, retryAfterSeconds, readSnapshot = false) {
24
25
  super(message, options);
25
26
  this.lastEnvelope = lastEnvelope;
26
27
  this.retryAfterSeconds = retryAfterSeconds;
28
+ this.readSnapshot = readSnapshot;
27
29
  this.name = "StackWatchError";
28
30
  }
29
31
  }
@@ -55,6 +57,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
55
57
  landGatePending: null,
56
58
  };
57
59
  let terminal = null;
60
+ let readSnapshot = false;
58
61
  const seal = (snapshot, retryAfterSeconds) => ({
59
62
  ...snapshot,
60
63
  watch: stackWatchObligation({
@@ -116,7 +119,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
116
119
  throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, seal({
117
120
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
118
121
  assessment: "unavailable",
119
- }, response.retryAfterSeconds), undefined, response.retryAfterSeconds);
122
+ }, response.retryAfterSeconds), undefined, response.retryAfterSeconds, readSnapshot);
120
123
  }
121
124
  throw timeout();
122
125
  };
@@ -149,6 +152,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
149
152
  };
150
153
  const evaluate = (stack, entries) => {
151
154
  const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending, queueWait } = stackBlockers(stack, entries, cursor, now());
155
+ readSnapshot = true;
152
156
  if (bounce)
153
157
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
154
158
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
package/dist/ui/banner.js CHANGED
@@ -227,6 +227,9 @@ const WHATS_NEW = {
227
227
  "0.3.63": [
228
228
  "Stack create starts a new stack unless --extend",
229
229
  ],
230
+ "0.3.64": [
231
+ "Memory commands and Overdrive merge batches",
232
+ ],
230
233
  };
231
234
  function densityFromVariant(variant) {
232
235
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.63",
3
+ "version": "0.3.64",
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",
@@ -39,6 +39,8 @@
39
39
  },
40
40
  "files": [
41
41
  "dist",
42
+ "!dist/.tsbuildinfo",
43
+ "!dist/.build-inputs",
42
44
  "skill",
43
45
  "LICENSE",
44
46
  "README.md"