mergestorm 0.3.61 → 0.3.63

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.
@@ -72,10 +72,26 @@ function vortexFindingsRestAtHead(layer) {
72
72
  function vortexChangesRequestedAtHead(layer) {
73
73
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
74
74
  }
75
- function cycloneHandoffAtHead(stack, layer) {
75
+ export const CYCLONE_NOT_RUN_ATTENTION_MS = 30 * 60_000;
76
+ export const CYCLONE_NOT_RUN_REASON = "not_run";
77
+ function cycloneNotRunAtHead(layer, nowMs) {
78
+ const review = layer.vortexReview;
79
+ if (layer.cycloneFindingsTakenAtHead !== false || layer.vortexStatus !== "findings")
80
+ return false;
81
+ if (review?.status !== "done" || !sameHead(review.head_sha, layer.headSha))
82
+ return false;
83
+ const settledMs = Date.parse(review.settled_at ?? "");
84
+ return Number.isFinite(settledMs) && nowMs - settledMs >= CYCLONE_NOT_RUN_ATTENTION_MS;
85
+ }
86
+ function cycloneHandoffAtHead(stack, layer, nowMs) {
76
87
  if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
77
88
  return layer.cycloneHandoff.reason;
78
- return stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
89
+ if (layer.cycloneStatus === "failed")
90
+ return "failed";
91
+ const off = stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
92
+ if (off)
93
+ return off;
94
+ return cycloneNotRunAtHead(layer, nowMs) ? CYCLONE_NOT_RUN_REASON : null;
79
95
  }
80
96
  const CYCLONE_HANDOFF_WORDS = {
81
97
  failed: "Cyclone's patch failed, so it pushed nothing",
@@ -93,6 +109,11 @@ const CYCLONE_HANDOFF_WORDS = {
93
109
  unsupported_language: "the findings are in a language Cyclone does not patch",
94
110
  policy_owner_changed: "the stack's owner changed while the patch was queued",
95
111
  patch_job_lost: "Cyclone's patch job was lost before it finished",
112
+ pr_state_unavailable: "Cyclone could not read the PR's state, so it pushed nothing",
113
+ left_for_human: "Cyclone did not patch these findings and pushed nothing",
114
+ hold: "the review told Cyclone to hold, so it did not patch these findings",
115
+ vortex_patch_cap: "Cyclone already patched this PR as many times as it may",
116
+ [CYCLONE_NOT_RUN_REASON]: `Cyclone has not run on this head in the ${CYCLONE_NOT_RUN_ATTENTION_MS / 60_000} minutes since Vortex posted these findings, and nothing is patching it`,
96
117
  conflict_needs_person: "Cyclone could not resolve the conflict and left it for a person",
97
118
  conflict_unresolved: "Cyclone's conflict resolution failed",
98
119
  auto_resolve_daily_cap: "Cyclone already resolved conflicts on this PR twice today, its daily cap",
@@ -179,17 +200,27 @@ const CYCLONE_HELD_FINDINGS_REASONS = [
179
200
  "pr_state_unavailable",
180
201
  "vortex_patch_cap",
181
202
  "patch_job_lost",
203
+ "unsupported_language",
204
+ "credits_exhausted",
205
+ "no_billing",
206
+ "policy_owner_changed",
182
207
  ];
183
208
  function vortexImportantFindingsHeldAtHead(layer) {
184
209
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "reviewed" &&
185
210
  sameHead(layer.cycloneHandoff?.headSha, layer.headSha) &&
186
211
  CYCLONE_HELD_FINDINGS_REASONS.includes(layer.cycloneHandoff.reason);
187
212
  }
188
- function vortexFindingsLeftForPerson(stack, layer) {
213
+ function seamApprovedAtHead(stack, layer) {
214
+ const member = (stack.unit?.members ?? []).find((entry) => entry.prNumber === layer.prNumber);
215
+ return member?.seamState?.trim().toLowerCase() === "approved" && sameHead(member.seamReviewedSha, layer.headSha);
216
+ }
217
+ function vortexFindingsLeftForPerson(stack, layer, nowMs) {
189
218
  if (layerAgentsBusy(layer).cyclone)
190
219
  return false;
220
+ if (seamApprovedAtHead(stack, layer))
221
+ return false;
191
222
  return vortexImportantFindingsHeldAtHead(layer) ||
192
- (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer) !== null);
223
+ (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer, nowMs) !== null);
193
224
  }
194
225
  function quotaSkippedReview(layer) {
195
226
  const review = layer.vortexReview;
@@ -304,7 +335,7 @@ function headChecksRerunning(layer) {
304
335
  return layer.ciStatus === "pending" && !!checks && checks.failure === 0 && checks.pending > 0 && checks.cancelledOnly !== true;
305
336
  }
306
337
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
307
- function layerAttention(stack, layer, queue, cursor) {
338
+ function layerAttention(stack, layer, queue, cursor, nowMs) {
308
339
  const none = { blocker: null, reason: null, bounceKind: null };
309
340
  if (stack.archivedAt)
310
341
  return none;
@@ -357,7 +388,7 @@ function layerAttention(stack, layer, queue, cursor) {
357
388
  if (landing && !landGateIsPending(landing))
358
389
  return hardBlock(landing, "land_gate");
359
390
  }
360
- if (vortexFindingsLeftForPerson(stack, layer))
391
+ if (vortexFindingsLeftForPerson(stack, layer, nowMs))
361
392
  return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
362
393
  }
363
394
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
@@ -494,9 +525,6 @@ export function autoLandWaitLayer(stack) {
494
525
  function autoLandWaitOn(stack, layer) {
495
526
  return autoLandWaitLayer(stack)?.prNumber === layer.prNumber ? armedAutoLandWait(stack) : null;
496
527
  }
497
- export function autoLandWaitBlocker(stack, layer, nowMs) {
498
- return autoLandWaitNamed(stack, layer, nowMs)?.blocker ?? null;
499
- }
500
528
  function autoLandWaitNamed(stack, layer, nowMs) {
501
529
  const wait = autoLandWaitOn(stack, layer);
502
530
  if (!wait)
@@ -679,7 +707,7 @@ export function agentsCannotClear(reason, stack, layer) {
679
707
  return false;
680
708
  return AGENTS_CANNOT_CLEAR.includes(reason);
681
709
  }
682
- export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
710
+ export function stackRepair(stack, attention, layer, bounce, queueWait = null, nowMs = Date.now()) {
683
711
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
684
712
  return null;
685
713
  const reason = attention.reason;
@@ -863,7 +891,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
863
891
  }
864
892
  if (reason === "vortex_findings_need_person") {
865
893
  const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
866
- const handoff = cycloneHandoffAtHead(stack, layer);
894
+ const handoff = cycloneHandoffAtHead(stack, layer, nowMs);
867
895
  return {
868
896
  kind: "vortex_findings",
869
897
  prNumber: layer.prNumber,
@@ -871,7 +899,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
871
899
  branch: layer.branch,
872
900
  handoff,
873
901
  findings,
874
- steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
902
+ steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them undone${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, so nothing will patch them and Auto land waits. You fix them: read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Dismiss a finding you verified is wrong yourself: mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> --finding <id> --reason <why> --evidence <link>. Ask the human only for a product or design decision. Do not retarget the PR base.`,
875
903
  };
876
904
  }
877
905
  if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
@@ -929,6 +957,15 @@ function landPrMissing(stack, candidate) {
929
957
  },
930
958
  };
931
959
  }
960
+ function layerBusy(layer) {
961
+ if (!layer)
962
+ return false;
963
+ const busy = layerAgentsBusy(layer);
964
+ return busy.cyclone || busy.vortex;
965
+ }
966
+ function sameBlocker(a, b) {
967
+ return a.prNumber === b.prNumber && a.blocker === b.blocker;
968
+ }
932
969
  /** Pair-gate attention plus live hard blocks on every other open layer. */
933
970
  export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
934
971
  const candidate = currentLayer(stack);
@@ -946,11 +983,13 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
946
983
  let attentionLayer;
947
984
  let bounce;
948
985
  const issues = [];
986
+ const found = [];
949
987
  const mergeStateUnknownPrs = new Set();
950
988
  const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
951
989
  for (const layer of open) {
952
990
  const selected = layer.prNumber === candidate?.prNumber;
953
- let result = layerAttention(stack, layer, selected ? queue : [], cursor);
991
+ const inScope = selected || !candidate || layer.position > candidate.position;
992
+ let result = layerAttention(stack, layer, selected ? queue : [], cursor, nowMs);
954
993
  let disarmed;
955
994
  if (!result.blocker && selected) {
956
995
  disarmed = disarmingBounce(stack, queue);
@@ -978,10 +1017,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
978
1017
  result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, reason: "queue_stalled", bounceKind: null };
979
1018
  }
980
1019
  if (!result.blocker) {
981
- if (vortexRetryScheduled(layer) && (selected || !candidate || layer.position > candidate.position)) {
1020
+ if (vortexRetryScheduled(layer) && inScope) {
982
1021
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null, blocker: VORTEX_RETRY_SCHEDULED, bounceKind: null });
983
1022
  }
984
- if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
1023
+ if (mergeabilityPending(layer) && inScope) {
985
1024
  mergeStateUnknownPrs.add(layer.prNumber);
986
1025
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
987
1026
  blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
@@ -990,6 +1029,9 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
990
1029
  }
991
1030
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
992
1031
  blocker: result.blocker, bounceKind: result.bounceKind };
1032
+ if (inScope || result.reason === "unit_abandoned" || layer === child || waitNamed) {
1033
+ found.push({ issue, reason: result.reason, layer, bounce: result.bounce });
1034
+ }
993
1035
  if (selected ||
994
1036
  result.reason === "unit_abandoned" ||
995
1037
  (!attention && (layer === child || waitNamed))) {
@@ -1001,7 +1043,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1001
1043
  issues.push({ prNumber: disarmed.bounceDetail?.prNumber ?? layer.prNumber,
1002
1044
  headSha: disarmed.bounceDetail?.headSha ?? null, blocker: mergeQueueBounceLabel(disarmed), bounceKind: result.bounceKind });
1003
1045
  }
1004
- else if (!candidate || layer.position > candidate.position)
1046
+ else if (inScope)
1005
1047
  issues.push(issue);
1006
1048
  }
1007
1049
  if (!attention) {
@@ -1011,6 +1053,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1011
1053
  attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting.blocker, bounceKind: null };
1012
1054
  attentionReason = waiting.reason;
1013
1055
  attentionLayer = unlisted;
1056
+ found.push({ issue: attention, reason: waiting.reason, layer: unlisted, bounce: undefined });
1014
1057
  }
1015
1058
  }
1016
1059
  const queued = queueWait && candidate && !queueWait.stalled
@@ -1025,13 +1068,15 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1025
1068
  attention = landMissing.attention;
1026
1069
  attentionReason = landMissing.reason;
1027
1070
  attentionLayer = undefined;
1071
+ found.unshift({ issue: landMissing.attention, reason: landMissing.reason, layer: undefined, bounce: undefined,
1072
+ repair: landMissing.repair });
1028
1073
  }
1029
1074
  const classified = attention && attentionReason
1030
1075
  ? { ...attention, reason: attentionReason }
1031
1076
  : null;
1032
1077
  const focus = attentionLayer ?? candidate;
1033
- const agents = focus ? layerAgents(focus) : null;
1034
- const busy = [];
1078
+ let agents = focus ? layerAgents(focus) : null;
1079
+ let busy = [];
1035
1080
  if (attention && agents) {
1036
1081
  for (const agent of ["cyclone", "vortex"]) {
1037
1082
  if (agents.busy[agent])
@@ -1045,17 +1090,39 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1045
1090
  const landGatePending = landLayer && landing && landGateIsPending(landing)
1046
1091
  ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
1047
1092
  : null;
1093
+ let held = null;
1094
+ let shownIssues = issues;
1095
+ let repair;
1048
1096
  if (busy.length && attention && classified && agentsCannotClear(classified.reason, stack, attentionLayer)) {
1049
- const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1050
- return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
1051
- repair: stackRepair(stack, classified, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
1097
+ held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1098
+ shownIssues = [attention, ...issues];
1099
+ repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait, nowMs);
1100
+ attention = null;
1101
+ bounce = undefined;
1052
1102
  }
1053
- if (busy.length) {
1054
- return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
1055
- queueWait: queued };
1103
+ else if (busy.length) {
1104
+ repair = null;
1105
+ attention = null;
1106
+ bounce = undefined;
1107
+ }
1108
+ else {
1109
+ repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait, nowMs);
1110
+ }
1111
+ if (!attention) {
1112
+ const next = found.find((item) => !layerBusy(item.layer));
1113
+ if (next) {
1114
+ attention = next.issue;
1115
+ repair = next.repair !== undefined
1116
+ ? next.repair
1117
+ : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait, nowMs);
1118
+ bounce = next.bounce;
1119
+ if (next.layer)
1120
+ agents = layerAgents(next.layer);
1121
+ busy = [];
1122
+ shownIssues = shownIssues.filter((issue) => !sameBlocker(issue, next.issue));
1123
+ }
1056
1124
  }
1057
- return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
1058
- repair: landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait), landGatePending,
1125
+ return { attention, held, issues: shownIssues, currentCandidate, bounce, busy, agents, repair, landGatePending,
1059
1126
  queueWait: queued };
1060
1127
  }
1061
1128
  function mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention) {
@@ -59,6 +59,7 @@ export type StackVortexReviewView = {
59
59
  phase: string | null;
60
60
  /** Earliest live job start while `reviewing`; null when lease-only. */
61
61
  started_at: string | null;
62
+ settled_at?: string | null;
62
63
  /** Stop control visibility: true only while a pass can actually be cancelled. */
63
64
  stoppable: boolean;
64
65
  /** Which table spoke: the authority row, a live job, a lease, or nothing. */
@@ -225,6 +226,7 @@ export type StackLayerDto = {
225
226
  cycloneConflictPendingSince?: string | null;
226
227
  cycloneCiHandoff?: StackCycloneHandoff | null;
227
228
  cycloneCiPendingSince?: string | null;
229
+ cycloneFindingsTakenAtHead?: boolean;
228
230
  conflictDetail: string | null;
229
231
  restackError?: RestackError | null;
230
232
  lastRestackedSha: string | null;
@@ -19,8 +19,6 @@ export type StackMeta = {
19
19
  /** Index into `stacks` for create/submit. */
20
20
  active: number;
21
21
  };
22
- /** Exported only for legacy migration/tests. New state never uses this path. */
23
- export declare const STACK_META_REL: string;
24
22
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
25
23
  export declare function loadStackMeta(cwd?: string, stacksRoot?: string): Promise<StackMeta | null>;
26
24
  export declare function emptyStackMeta(trunk: string): StackMeta;
@@ -3,11 +3,10 @@
3
3
  * Stored outside repositories under ~/.mergestorm/stacks/<repo-id>/.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile, } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { CommandError, isCommandErrorCode } from "./errors.js";
9
- import { git } from "./git.js";
10
- import { cliStacksRoot, gitTopLevel, LEGACY_STACK_META_REL, legacyStackMetaPath, repoStateDir, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
9
+ import { cliStacksRoot, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
11
10
  const LOCAL_STACK_FLAGS = [
12
11
  "autoEnqueueWhenReady",
13
12
  "autoReviewOverride",
@@ -15,8 +14,6 @@ const LOCAL_STACK_FLAGS = [
15
14
  "autoResolveConflictsOverride",
16
15
  "autoFixCiOverride",
17
16
  ];
18
- /** Exported only for legacy migration/tests. New state never uses this path. */
19
- export const STACK_META_REL = LEGACY_STACK_META_REL;
20
17
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
21
18
  return resolveStackMetaPath(cwd, stacksRoot);
22
19
  }
@@ -64,17 +61,6 @@ function normalizeMeta(raw) {
64
61
  }
65
62
  return { version: 2, trunk: obj.trunk, stacks, active };
66
63
  }
67
- if (obj.version === 1) {
68
- if (!Array.isArray(obj.layers) || !obj.layers.every(isLayer)) {
69
- throw new Error("Stack authoring state has malformed legacy layers; run `mg stack reset --force` to clear it.");
70
- }
71
- return {
72
- version: 2,
73
- trunk: obj.trunk,
74
- stacks: [{ layers: obj.layers }],
75
- active: 0,
76
- };
77
- }
78
64
  throw new Error("Stack authoring state has an unknown version; run `mg stack reset --force` to clear it.");
79
65
  }
80
66
  function parseStateJson(raw) {
@@ -96,71 +82,17 @@ async function readIfExists(file) {
96
82
  throw err;
97
83
  }
98
84
  }
99
- async function removeLegacyFile(file, cwd = process.cwd(), force = false) {
100
- const top = gitTopLevel(cwd);
101
- const rel = path.relative(top, file);
102
- try {
103
- git(["rm", "-f", "--", rel], top);
104
- }
105
- catch (err) {
106
- // Fall back to a plain unlink only for untracked files; otherwise the
107
- // index entry would remain after the worktree copy is gone.
108
- if (git(["ls-files", "--", rel], top).trim() !== "") {
109
- if (!force) {
110
- throw new Error(`git rm failed for tracked ${rel}: ${err instanceof Error ? err.message : String(err)}`);
111
- }
112
- git(["update-index", "--force-remove", "--", rel], top);
113
- }
114
- await unlink(file);
115
- }
116
- try {
117
- await rmdir(path.dirname(file));
118
- }
119
- catch {
120
- // Leave a non-empty .mergestorm directory untouched.
121
- }
122
- }
123
85
  export async function loadStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
124
86
  const file = stackMetaPath(cwd, stacksRoot);
125
- const legacyFile = legacyStackMetaPath(cwd);
126
87
  const raw = await readIfExists(file);
127
- const legacyRaw = await readIfExists(legacyFile);
128
88
  if (raw !== null) {
129
89
  const meta = normalizeMeta(parseStateJson(raw));
130
90
  if (meta === null) {
131
91
  throw new CommandError("Stack authoring state is not an object; run `mg stack reset --force` to clear it.");
132
92
  }
133
- if (legacyRaw !== null) {
134
- const stateDir = repoStateDir(cwd, stacksRoot);
135
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
136
- const backup = path.join(stateDir, `legacy-conflict-${stamp}.json`);
137
- try {
138
- await mkdir(stateDir, { recursive: true, mode: 0o700 });
139
- await chmod(stateDir, 0o700);
140
- await copyFile(legacyFile, backup);
141
- await chmod(backup, 0o600);
142
- await removeLegacyFile(legacyFile, cwd);
143
- }
144
- catch (err) {
145
- throw new CommandError(`Failed to preserve legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
146
- }
147
- console.warn(`Warning: global stack state already existed; preserved legacy state at ${backup}.`);
148
- }
149
93
  return meta;
150
94
  }
151
- if (legacyRaw === null)
152
- return null;
153
- const migrated = normalizeMeta(parseStateJson(legacyRaw));
154
- if (!migrated)
155
- return null;
156
- try {
157
- await saveStackMeta(migrated, cwd, stacksRoot);
158
- await removeLegacyFile(legacyFile, cwd);
159
- }
160
- catch (err) {
161
- throw new CommandError(`Failed to migrate legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
162
- }
163
- return migrated;
95
+ return null;
164
96
  }
165
97
  export function emptyStackMeta(trunk) {
166
98
  return {
@@ -225,10 +157,8 @@ export async function saveStackMeta(meta, cwd = process.cwd(), stacksRoot = cliS
225
157
  /** Explicit recovery. Does not touch branches, PRs, or registered server stacks. */
226
158
  export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
227
159
  let stateFile;
228
- let legacyFile;
229
160
  try {
230
161
  stateFile = stackMetaPath(cwd, stacksRoot);
231
- legacyFile = legacyStackMetaPath(cwd);
232
162
  }
233
163
  catch (err) {
234
164
  // Outside a worktree / deleted main repo: reset is a no-op (nothing to clear).
@@ -238,14 +168,6 @@ export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacks
238
168
  throw err;
239
169
  }
240
170
  await rm(stateFile, { force: true });
241
- try {
242
- await removeLegacyFile(legacyFile, cwd, true);
243
- }
244
- catch (err) {
245
- const code = err?.code;
246
- if (code !== "ENOENT")
247
- throw err;
248
- }
249
171
  }
250
172
  export function appendLayer(meta, layer) {
251
173
  const current = activeLayers(meta);
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, LandedQueueReadError, readLandedQueueEntry } from "./api.js";
3
3
  import { isTransientReviewPollError } from "./commands/review-client.js";
4
+ import { stackWatchBackgroundCommand } from "./stack-watch-obligation.js";
4
5
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
5
6
  export const STACK_WATCH_MARKER = "MS-WATCH";
6
7
  export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
@@ -30,7 +31,9 @@ export function stackWatchAttentionKey(envelope) {
30
31
  return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${blockerWithoutElapsedMinutes(envelope.blocker)}`;
31
32
  }
32
33
  export function stackWatchAttentionLine(envelope) {
33
- return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}`;
34
+ const restart = envelope.cursor.bounceId
35
+ ? ` restart=${JSON.stringify(stackWatchBackgroundCommand(envelope.stackId, envelope.cursor))}` : "";
36
+ return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}${restart}`;
34
37
  }
35
38
  export function stackWatchLandedLine(envelope) {
36
39
  return `${STACK_WATCH_MARKER} LANDED stack=${envelope.stackId} reason=${envelope.watch.reason}`;
@@ -1,7 +1,10 @@
1
1
  import type { StackDto } from "./stack-dto.js";
2
2
  export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
3
3
  export declare const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
4
- export declare function stackWatchBackgroundCommand(stackId: string): string;
4
+ export declare function stackWatchBackgroundCommand(stackId: string, cursor?: {
5
+ afterFinishedAt?: string | null;
6
+ bounceId?: string | null;
7
+ } | null): string;
5
8
  export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
6
9
  export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed" | "landing_unconfirmed";
7
10
  export type StackWatchNextArgs = {
@@ -1,7 +1,10 @@
1
1
  export const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
2
2
  export const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
3
- export function stackWatchBackgroundCommand(stackId) {
4
- return `mg stack watch ${stackId}`;
3
+ function shellArg(value) {
4
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
5
+ }
6
+ export function stackWatchBackgroundCommand(stackId, cursor) {
7
+ return `mg stack watch ${shellArg(stackId)}${cursor?.bounceId ? ` --bounce-id ${shellArg(cursor.bounceId)}` : ""}${cursor?.afterFinishedAt ? ` --after-finished-at ${shellArg(cursor.afterFinishedAt)}` : ""}`;
5
8
  }
6
9
  const DONE_MESSAGES = {
7
10
  landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
@@ -53,7 +56,7 @@ export function stackWatchObligation(input) {
53
56
  : input.status === "failed"
54
57
  ? "failed"
55
58
  : input.attention ? "attention" : input.unread ? "unread" : "open";
56
- const background = stackWatchBackgroundCommand(input.stackId);
59
+ const background = stackWatchBackgroundCommand(input.stackId, input.cursor);
57
60
  const parts = [
58
61
  STACK_WATCH_NOT_DONE_SENTENCE,
59
62
  `If your host has a confirmed notification that resumes this task, run \`${background}\` as a background command, notify on output matching ${STACK_WATCH_NOTIFY_PATTERN}, and end your turn. The command monitors the stack; it cannot wake an agent by itself, and a process left running is not a notification. Without a confirmed notification, keep using stack_wait with the returned cursor, or report that automatic follow-up is unavailable before ending the turn.`,
@@ -66,7 +69,7 @@ export function stackWatchObligation(input) {
66
69
  parts.push(`This read failed, which does not end the task. Call stack_wait once more with the same cursor; if that fails too, tell the human that stack ${input.stackId} is not landed and is no longer being watched.`);
67
70
  }
68
71
  else if (reason === "attention" && input.attention) {
69
- parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed, or start \`${background} --head <pushed-sha>\` in the background.`);
72
+ parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed, or start \`${background} --head <pushed-sha>\` in the background.${input.cursor?.bounceId ? ` If you handle it without a push (for example by turning Auto land back on), start \`${background}\` so this bounce is not reported again.` : ""}`);
70
73
  }
71
74
  else if (reason === "unread") {
72
75
  parts.push("This tool did not read the stack's state; stack_wait will.");
@@ -154,8 +154,9 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
154
154
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
155
155
  const held = busy[0];
156
156
  const shown = attention ?? named;
157
+ const waitingNamed = attention ? null : named;
157
158
  lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending, queueWait,
158
- actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
159
+ actAfter: waitingNamed?.actAfter ?? null, waitingOn: waitingNamed?.waitingOn ?? [],
159
160
  blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
160
161
  prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
161
162
  headSha: shown ? shown.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
package/dist/ui/banner.js CHANGED
@@ -221,6 +221,12 @@ const WHATS_NEW = {
221
221
  "0.3.61": [
222
222
  "Watch names a job Cyclone's push skips",
223
223
  ],
224
+ "0.3.62": [
225
+ "Watch wakes for a blocker on any layer",
226
+ ],
227
+ "0.3.63": [
228
+ "Stack create starts a new stack unless --extend",
229
+ ],
224
230
  };
225
231
  function densityFromVariant(variant) {
226
232
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.61",
3
+ "version": "0.3.63",
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",
@@ -20,7 +20,14 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": "./dist/cli.js",
23
- "./client": "./dist/client/index.js"
23
+ "./client": {
24
+ "mergestorm-source": "./src/client/index.ts",
25
+ "default": "./dist/client/index.js"
26
+ },
27
+ "./skill": {
28
+ "mergestorm-source": "./src/commands/skill.ts",
29
+ "default": "./dist/commands/skill.js"
30
+ }
24
31
  },
25
32
  "homepage": "https://mergestorm.ai/docs#review-jobs-api",
26
33
  "repository": {
package/skill/SKILL.md CHANGED
@@ -48,7 +48,7 @@ Cursor `~/.cursor/mcp.json`:
48
48
 
49
49
  ## Preconditions
50
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.
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. A hosted connection to the Mergestorm MCP server, the kind a chat agent uses, has no `review_submit`: it cannot read your checkout. With only that connection, tell the human that this review runs where the branch is checked out, with `mg` or a local MCP server, and stop. Do not install `mg` yourself to get around it.
52
52
  2. Call `credits` (or `mg credits --json`). If remaining standard credits are 0, say so and stop.
53
53
 
54
54
  Ignored bot authors live in Settings → Vortex ("Ignore bot PR authors"), not Agents. Local `review_submit` is a manual review and is not an ignore-list skip.