mergestorm 0.3.25 → 0.3.27

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.
@@ -12,6 +12,11 @@ export type StackAgentBusy = {
12
12
  agent: "vortex" | "cyclone";
13
13
  blocker: string;
14
14
  };
15
+ export type StackBusyAgent = StackAgentBusy["agent"];
16
+ export type StackHeldBlocker = StackBlocker & {
17
+ actAfter: "agents_idle";
18
+ waitingOn: StackBusyAgent[];
19
+ };
15
20
  export type StackLayerAgents = {
16
21
  prNumber: number;
17
22
  headSha: string | null;
@@ -27,16 +32,57 @@ export type StackLayerAgents = {
27
32
  cyclone: boolean;
28
33
  };
29
34
  };
35
+ export type StackRepairHint = {
36
+ kind: "restack_conflict" | "merge_conflict";
37
+ prNumber: number;
38
+ headSha: string | null;
39
+ branch: string;
40
+ liveParent: string | null;
41
+ files: string[];
42
+ steps: string;
43
+ } | {
44
+ kind: "ci_failure";
45
+ prNumber: number;
46
+ headSha: string | null;
47
+ branch: string;
48
+ failingCheck: string | null;
49
+ steps: string;
50
+ };
51
+ export type StackLandGatePending = {
52
+ prNumber: number;
53
+ headSha: string | null;
54
+ reason: string;
55
+ };
56
+ export declare function landGateIsPending(reason: string | null | undefined): boolean;
30
57
  export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
31
58
  export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
32
59
  export declare function isMgParkBase(branch: string | null | undefined): boolean;
33
60
  /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
34
61
  export declare function boundDirty(layer: StackLayerDto): boolean;
62
+ export declare function mergeabilityPending(layer: StackLayerDto): boolean;
35
63
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
36
64
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
65
+ export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
66
+ export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
67
+ export declare function agentsCannotClear(blocker: string): boolean;
68
+ export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
37
69
  /** Pair-gate attention plus live hard blocks on every other open layer. */
38
70
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
71
+ attention: null;
72
+ held: StackHeldBlocker;
73
+ issues: StackBlocker[];
74
+ currentCandidate: {
75
+ prNumber: number;
76
+ headSha: string | null;
77
+ } | null;
78
+ bounce: undefined;
79
+ busy: StackAgentBusy[];
80
+ agents: StackLayerAgents | null;
81
+ repair: StackRepairHint | null;
82
+ landGatePending: StackLandGatePending | null;
83
+ } | {
39
84
  attention: StackBlocker | null;
85
+ held: null;
40
86
  issues: StackBlocker[];
41
87
  currentCandidate: {
42
88
  prNumber: number;
@@ -45,6 +91,8 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
45
91
  bounce: MergeQueueEntryDto | undefined;
46
92
  busy: StackAgentBusy[];
47
93
  agents: StackLayerAgents | null;
94
+ repair: StackRepairHint | null;
95
+ landGatePending: StackLandGatePending | null;
48
96
  };
49
97
  /** Compact contract text shared by status and wait summaries. */
50
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[]): string;
98
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null): string;
@@ -1,4 +1,8 @@
1
1
  import { deriveStackAgentsBusy, mergeQueueBounceLabel, restackRetryPending, } from "./stack-dto.js";
2
+ export function landGateIsPending(reason) {
3
+ const token = reason?.trim().replace(/^landing blocked:\s*/i, "").match(/^[a-z_]+/i)?.[0] ?? "";
4
+ return /_(?:pending|running)$/i.test(token);
5
+ }
2
6
  export function layerAgentsBusy(layer) {
3
7
  return layer.agentsBusy ?? deriveStackAgentsBusy(layer);
4
8
  }
@@ -26,6 +30,12 @@ export function boundDirty(layer) {
26
30
  return (!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
27
31
  (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty");
28
32
  }
33
+ export function mergeabilityPending(layer) {
34
+ if (isMgParkBase(layer.parentBranch))
35
+ return false;
36
+ return layer.mergeable == null && !!layer.headSha && layer.mergeableHeadSha === layer.headSha &&
37
+ layer.mergeableState?.toLowerCase() === "pending";
38
+ }
29
39
  export function sameHead(a, b) {
30
40
  const left = a?.trim().toLowerCase();
31
41
  const right = b?.trim().toLowerCase();
@@ -88,6 +98,9 @@ function layerAttention(stack, layer, queue, cursor) {
88
98
  const name = layer.checks?.failingName?.trim();
89
99
  return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
90
100
  }
101
+ if (layer.agentRuns?.some((run) => run.agent === "cyclone" && run.status === "failed" &&
102
+ sameHead(run.sha, layer.headSha)))
103
+ return hardBlock("Cyclone failed");
91
104
  const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
92
105
  ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
93
106
  if (tempest)
@@ -98,8 +111,9 @@ function layerAttention(stack, layer, queue, cursor) {
98
111
  if (seam)
99
112
  return hardBlock(seam);
100
113
  if (stack.unit?.landPrNumber === layer.prNumber) {
101
- if (stack.unit.landingBlockReason?.trim())
102
- return hardBlock(stack.unit.landingBlockReason.trim());
114
+ const landing = stack.unit.landingBlockReason?.trim();
115
+ if (landing && !landGateIsPending(landing))
116
+ return hardBlock(landing);
103
117
  if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
104
118
  return hardBlock("Tempest failed");
105
119
  }
@@ -115,6 +129,81 @@ function layerAttention(stack, layer, queue, cursor) {
115
129
  return none;
116
130
  return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
117
131
  }
132
+ function isTerminalLayer(layer) {
133
+ return layer.state === "merged" || layer.state === "closed";
134
+ }
135
+ export function conflictLiveParent(stack, layer) {
136
+ const promoted = (candidate) => !!stack.unit?.members?.some((member) => member.prNumber === candidate.prNumber && member.promotedHeadSha?.trim());
137
+ const sibling = stack.layers
138
+ .filter((candidate) => candidate.prNumber > 0 && candidate.position < layer.position &&
139
+ !isTerminalLayer(candidate) && !promoted(candidate))
140
+ .sort((a, b) => b.position - a.position)[0];
141
+ if (sibling)
142
+ return sibling.branch;
143
+ const retired = new Set([
144
+ ...stack.layers.filter((candidate) => isTerminalLayer(candidate) || promoted(candidate)).map((candidate) => candidate.branch),
145
+ ...(stack.unit?.members ?? []).filter((member) => member.promotedHeadSha?.trim()).map((member) => member.branch),
146
+ ]);
147
+ const usable = (branch) => !!branch?.trim() && !isMgParkBase(branch) && !retired.has(branch) && branch !== layer.branch;
148
+ for (const branch of [layer.restackError?.from?.branch, layer.parentBranch, stack.unit?.branch, stack.trunkBranch]) {
149
+ if (usable(branch))
150
+ return branch;
151
+ }
152
+ return null;
153
+ }
154
+ function conflictFiles(layer, bounce) {
155
+ const paths = bounce?.bounceDetail?.conflictPaths?.filter((path) => path.trim());
156
+ if (paths?.length)
157
+ return [...paths];
158
+ const detail = layer.restackError?.kind === "rebase_conflict" ? layer.restackError.detail : layer.conflictDetail;
159
+ const listed = detail?.match(/^conflict in (.+)$/)?.[1];
160
+ return listed ? listed.split(", ").map((path) => path.trim()).filter(Boolean) : [];
161
+ }
162
+ export function conflictRepairSteps(branch, liveParent) {
163
+ if (!liveParent) {
164
+ return `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.`;
165
+ }
166
+ 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.`;
167
+ }
168
+ function landPrLiveParent(stack, layer) {
169
+ const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
170
+ return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
171
+ }
172
+ export function agentsCannotClear(blocker) {
173
+ return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
174
+ blocker.startsWith("Merge conflicts");
175
+ }
176
+ export function stackRepair(stack, attention, layer, bounce) {
177
+ if (!attention || !layer || attention.prNumber !== layer.prNumber)
178
+ return null;
179
+ const landPr = !!stack.unit && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
180
+ const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
181
+ const mergeConflict = attention.blocker.startsWith("Merge conflicts");
182
+ if (restackConflict || mergeConflict) {
183
+ const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
184
+ return {
185
+ kind: restackConflict ? "restack_conflict" : "merge_conflict",
186
+ prNumber: layer.prNumber,
187
+ headSha: layer.headSha ?? null,
188
+ branch: layer.branch,
189
+ liveParent,
190
+ files: conflictFiles(layer, bounce),
191
+ steps: conflictRepairSteps(layer.branch, liveParent),
192
+ };
193
+ }
194
+ if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
195
+ const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
196
+ return {
197
+ kind: "ci_failure",
198
+ prNumber: layer.prNumber,
199
+ headSha: layer.headSha ?? null,
200
+ branch: layer.branch,
201
+ failingCheck,
202
+ steps: `Read the failing ${failingCheck ? `check ${failingCheck}` : "checks"} on ${layer.branch} (gh run list --branch ${layer.branch}, then gh run view <run-id> --log-failed), fix it on ${layer.branch} with the smallest patch, run that check locally, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push. Do not retarget the PR base.`,
203
+ };
204
+ }
205
+ return null;
206
+ }
118
207
  /** Pair-gate attention plus live hard blocks on every other open layer. */
119
208
  export function stackBlockers(stack, entries = [], cursor = {}) {
120
209
  const candidate = currentLayer(stack);
@@ -134,8 +223,13 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
134
223
  for (const layer of open) {
135
224
  const selected = layer.prNumber === candidate?.prNumber;
136
225
  const result = layerAttention(stack, layer, selected ? queue : [], cursor);
137
- if (!result.blocker)
226
+ if (!result.blocker) {
227
+ if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
228
+ issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
229
+ blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
230
+ }
138
231
  continue;
232
+ }
139
233
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
140
234
  blocker: result.blocker, bounceKind: result.bounceKind };
141
235
  if (selected || (!attention && layer === child)) {
@@ -155,16 +249,35 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
155
249
  busy.push({ prNumber: attention.prNumber, headSha: attention.headSha, agent, blocker: attention.blocker });
156
250
  }
157
251
  }
252
+ const landing = stack.unit?.landingBlockReason?.trim();
253
+ const landLayer = stack.unit?.landPrNumber != null && !stack.archivedAt
254
+ ? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber)
255
+ : undefined;
256
+ const landGatePending = landLayer && landing && landGateIsPending(landing)
257
+ ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
258
+ : null;
259
+ if (busy.length && attention && agentsCannotClear(attention.blocker)) {
260
+ const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
261
+ return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
262
+ repair: stackRepair(stack, attention, attentionLayer, undefined), landGatePending };
263
+ }
158
264
  if (busy.length)
159
- return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents };
160
- return { attention, issues, currentCandidate, bounce, busy, agents };
265
+ return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
266
+ return { attention, held: null, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
161
267
  }
162
268
  /** Compact contract text shared by status and wait summaries. */
163
- export function stackBlockersSummary(attention, issues, busy = []) {
164
- const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
165
- const working = busy.length
166
- ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map((entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews").join(" and ")}`
269
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null) {
270
+ const agentWork = (entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews";
271
+ const blocked = attention && actAfter && busy.length
272
+ ? ` · blocked after the agents: #${attention.prNumber} ${attention.blocker} (plan the fix; act once ${busy.map(agentWork).join(" and ")} finish and the watch returns attention)`
273
+ : attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
274
+ const working = busy.length && !actAfter
275
+ ? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map(agentWork).join(" and ")}`
276
+ : "";
277
+ const listed = actAfter && attention ? issues.filter((issue) => issue.prNumber !== attention.prNumber || issue.blocker !== attention.blocker) : issues;
278
+ const labels = listed.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
279
+ const gate = landGatePending && !attention
280
+ ? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
167
281
  : "";
168
- const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
169
- return blocked + working + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
282
+ return blocked + working + gate + (listed.length ? ` · issues: ${labels}${listed.length > 3 ? `; +${listed.length - 3} more` : ""}` : "");
170
283
  }
@@ -5,6 +5,10 @@ export type RestackError = {
5
5
  attemptedAt: string;
6
6
  attempts: number;
7
7
  backupRef: string | null;
8
+ from?: {
9
+ branch: string;
10
+ oldSha: string;
11
+ };
8
12
  };
9
13
  export declare function restackAttemptsExhausted(error: Pick<RestackError, "attempts">): boolean;
10
14
  export declare function pushRejectionIsRetryable(message: string): boolean;
@@ -154,6 +158,14 @@ export type StackLayerConflictResolution = {
154
158
  */
155
159
  dispatchId: string | null;
156
160
  };
161
+ export type StackLayerCiFixStatus = "verified" | "unverified" | "no_changes" | "failed";
162
+ export type StackLayerCiFix = {
163
+ status: StackLayerCiFixStatus;
164
+ failingCheck: string | null;
165
+ resultSha: string | null;
166
+ reason: string | null;
167
+ at: string;
168
+ };
157
169
  export type StackLayerDto = {
158
170
  branch: string;
159
171
  parentBranch: string | null;
@@ -228,6 +240,7 @@ export type StackLayerDto = {
228
240
  * layers and when the specialist never ran.
229
241
  */
230
242
  conflictResolution?: StackLayerConflictResolution | null;
243
+ ciFix?: StackLayerCiFix | null;
231
244
  };
232
245
  export type StackUnitMemberDto = {
233
246
  prNumber: number;
@@ -370,6 +383,7 @@ export type MergeQueueBounceDetail = {
370
383
  conflictDetail?: string;
371
384
  conflictPaths?: string[];
372
385
  message?: string;
386
+ warning?: string;
373
387
  };
374
388
  export type MergeQueueEntryDto = {
375
389
  id: string;
@@ -0,0 +1,43 @@
1
+ import type { StackDto } from "./stack-dto.js";
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
+ export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
4
+ export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
5
+ export type StackWatchNextArgs = {
6
+ stack_id: string;
7
+ enrolled_head_sha?: string | null;
8
+ after_finished_at?: string | null;
9
+ bounce_id?: string | null;
10
+ timeout_s: 45;
11
+ };
12
+ export type StackWatchNext = {
13
+ tool: "stack_wait";
14
+ args: StackWatchNextArgs;
15
+ command: string;
16
+ };
17
+ export type StackWatchObligation = {
18
+ done: boolean;
19
+ until: "landed";
20
+ reason: StackWatchReason;
21
+ next: StackWatchNext | null;
22
+ message: string;
23
+ };
24
+ export type StackWatchObligationInput = {
25
+ stackId: string;
26
+ terminal: StackWatchDoneReason | null;
27
+ cursor?: {
28
+ enrolledHeadSha?: string | null;
29
+ afterFinishedAt?: string | null;
30
+ bounceId?: string | null;
31
+ } | null;
32
+ status?: string;
33
+ attention?: {
34
+ prNumber: number;
35
+ blocker: string;
36
+ } | null;
37
+ retryAfterSeconds?: number;
38
+ unread?: boolean;
39
+ freshCursor?: boolean;
40
+ };
41
+ export declare function stackTerminalReason(stack: Pick<StackDto, "archivedAt" | "layers" | "unit"> | null | undefined): StackWatchDoneReason | null;
42
+ export declare function stackWatchNextArgs(stackId: string, cursor: StackWatchObligationInput["cursor"]): StackWatchNextArgs;
43
+ export declare function stackWatchObligation(input: StackWatchObligationInput): StackWatchObligation;
@@ -0,0 +1,79 @@
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
+ const DONE_MESSAGES = {
3
+ landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
4
+ closed: "Every PR in this stack is merged or closed and nothing is left to land, but not every PR landed. The watch is done; tell the human which PRs were closed without landing.",
5
+ archived: "This stack is archived: the human took it back. The watch is done; stop calling stack_wait for it and do not push to its PRs.",
6
+ not_found: "No stack with this id exists for this account. Mergestorm deletes a stack once none of its layers is open, so a stack you were watching has most likely landed; a mistyped id or another account's stack reads the same. The watch is done; confirm each PR is merged (gh pr view <n> --json state) before reporting it landed.",
7
+ };
8
+ export function stackTerminalReason(stack) {
9
+ if (!stack)
10
+ return "not_found";
11
+ if (stack.archivedAt)
12
+ return "archived";
13
+ const layers = stack.layers;
14
+ if (layers.some((layer) => layer.state !== "merged" && layer.state !== "closed"))
15
+ return null;
16
+ const unit = stack.unit;
17
+ if (layers.length === 0 && !unit)
18
+ return null;
19
+ const landPr = unit?.landPr;
20
+ if (landPr && landPr.prNumber > 0 && landPr.state !== "merged" && landPr.state !== "closed")
21
+ return null;
22
+ if (unit && unit.state !== "landed" && unit.state !== "abandoned")
23
+ return null;
24
+ const everyLayerMerged = layers.every((layer) => layer.state === "merged");
25
+ return everyLayerMerged && (!unit || unit.state === "landed") ? "landed" : "closed";
26
+ }
27
+ export function stackWatchNextArgs(stackId, cursor) {
28
+ return {
29
+ stack_id: stackId,
30
+ ...(cursor && cursor.enrolledHeadSha !== undefined ? { enrolled_head_sha: cursor.enrolledHeadSha } : {}),
31
+ ...(cursor && cursor.afterFinishedAt !== undefined ? { after_finished_at: cursor.afterFinishedAt } : {}),
32
+ ...(cursor && cursor.bounceId !== undefined ? { bounce_id: cursor.bounceId } : {}),
33
+ timeout_s: 45,
34
+ };
35
+ }
36
+ export function stackWatchObligation(input) {
37
+ if (input.terminal) {
38
+ return {
39
+ done: true,
40
+ until: "landed",
41
+ reason: input.terminal,
42
+ next: null,
43
+ message: DONE_MESSAGES[input.terminal],
44
+ };
45
+ }
46
+ const args = stackWatchNextArgs(input.stackId, input.cursor);
47
+ const reason = input.status === "rate_limited"
48
+ ? "rate_limited"
49
+ : input.status === "failed"
50
+ ? "failed"
51
+ : input.attention ? "attention" : input.unread ? "unread" : "open";
52
+ const parts = [
53
+ STACK_WATCH_NOT_DONE_SENTENCE,
54
+ `Watch stack ${input.stackId} as a whole until it lands or the human takes it back. A clean push, a submit, or one merged layer does not finish it: merging a lower layer can break a layer above it.`,
55
+ ];
56
+ if (reason === "rate_limited") {
57
+ parts.push(`Wait ${input.retryAfterSeconds !== undefined ? `${input.retryAfterSeconds}s` : "retry_after_seconds"} first, then call stack_wait with the same cursor.`);
58
+ }
59
+ else if (reason === "failed") {
60
+ 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.`);
61
+ }
62
+ else if (reason === "attention" && input.attention) {
63
+ 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.`);
64
+ }
65
+ else if (reason === "unread") {
66
+ parts.push("This tool did not read the stack's state; stack_wait will.");
67
+ }
68
+ parts.push(`Next call: stack_wait ${JSON.stringify(args)}.`);
69
+ if (input.freshCursor) {
70
+ parts.push("If you already hold a cursor from an earlier stack_wait on this stack, keep passing that cursor instead.");
71
+ }
72
+ return {
73
+ done: false,
74
+ until: "landed",
75
+ reason,
76
+ next: { tool: "stack_wait", args, command: `mg stack wait ${input.stackId} --json` },
77
+ message: parts.join(" "),
78
+ };
79
+ }
@@ -1,4 +1,5 @@
1
- import { type StackAgentBusy, type StackBlocker, type StackLayerAgents } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
2
+ import { type StackWatchObligation } from "./stack-watch-obligation.js";
2
3
  import { apiFetch } from "./api.js";
3
4
  import type { Config } from "./config.js";
4
5
  export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
@@ -27,7 +28,12 @@ export type StackWatchEnvelope = {
27
28
  } | null;
28
29
  assessment: "available" | "unavailable";
29
30
  busy: StackAgentBusy[];
31
+ actAfter: StackHeldBlocker["actAfter"] | null;
32
+ waitingOn: StackBusyAgent[];
30
33
  agents: StackLayerAgents | null;
34
+ repair: StackRepairHint | null;
35
+ landGatePending: StackLandGatePending | null;
36
+ watch: StackWatchObligation;
31
37
  };
32
38
  export type PollStackWatchOptions = {
33
39
  timeoutMs?: number;
@@ -1,4 +1,5 @@
1
1
  import { currentLayer, layerAgents, stackBlockers, } from "./stack-blockers.js";
2
+ import { stackTerminalReason, stackWatchObligation, } from "./stack-watch-obligation.js";
2
3
  import { setTimeout as sleep } from "node:timers/promises";
3
4
  import { apiFetch } from "./api.js";
4
5
  import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
@@ -47,10 +48,20 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
47
48
  let lastEnvelope = {
48
49
  schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
49
50
  bounceKind: null, prNumber: null, headSha: null, cursor,
50
- issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null,
51
+ issues: [], currentCandidate: null, assessment: "unavailable", busy: [], actAfter: null, waitingOn: [], agents: null, repair: null,
52
+ landGatePending: null,
51
53
  };
54
+ let terminal = null;
55
+ const seal = (snapshot, retryAfterSeconds) => ({
56
+ ...snapshot,
57
+ watch: stackWatchObligation({
58
+ stackId: id, terminal, cursor: snapshot.cursor, status: snapshot.status, retryAfterSeconds,
59
+ attention: snapshot.status === "attention" && snapshot.blocker && snapshot.prNumber != null
60
+ ? { prNumber: snapshot.prNumber, blocker: snapshot.blocker } : null,
61
+ }),
62
+ });
52
63
  let transientFailures = 0;
53
- const timeout = () => new StackWatchTimeoutError(lastEnvelope);
64
+ const timeout = () => new StackWatchTimeoutError(seal(lastEnvelope));
54
65
  const pause = async (ms) => {
55
66
  const remaining = Math.min(ms, Math.max(0, deadline - now()));
56
67
  if (remaining > 0)
@@ -76,11 +87,11 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
76
87
  throw timeout();
77
88
  if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
78
89
  transientFailures += 1;
79
- opts.onTick?.(lastEnvelope);
90
+ opts.onTick?.(seal(lastEnvelope));
80
91
  await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
81
92
  continue;
82
93
  }
83
- throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed", assessment: "unavailable" }, { cause: err });
94
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }), { cause: err });
84
95
  }
85
96
  if (response.status === 200)
86
97
  return response.body;
@@ -90,14 +101,14 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
90
101
  if (timeoutMs !== 0 && isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES &&
91
102
  (response.status !== 429 || retryWaitMs < deadline - now())) {
92
103
  transientFailures += 1;
93
- opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
104
+ opts.onTick?.(seal({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status }, response.status === 429 ? response.retryAfterSeconds : undefined));
94
105
  await pause(retryWaitMs);
95
106
  continue;
96
107
  }
97
- throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
108
+ throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, seal({
98
109
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
99
110
  assessment: "unavailable",
100
- }, undefined, response.retryAfterSeconds);
111
+ }, response.retryAfterSeconds), undefined, response.retryAfterSeconds);
101
112
  }
102
113
  throw timeout();
103
114
  };
@@ -106,16 +117,22 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
106
117
  const stack = snapshot && Array.isArray(snapshot.stacks)
107
118
  ? snapshot.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
108
119
  : undefined;
120
+ if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
121
+ terminal = "not_found";
122
+ throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [], actAfter: null, waitingOn: [],
123
+ prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
124
+ }
109
125
  if (!stack || !Array.isArray(stack.layers)) {
110
- throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
126
+ throw new StackWatchError("Stack snapshot missing or invalid", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
111
127
  }
128
+ terminal = stackTerminalReason(stack);
112
129
  return stack;
113
130
  };
114
131
  const queueRoute = `/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`;
115
132
  const readQueue = async (route, finalSnapshot = false) => {
116
133
  const queue = await request(route, finalSnapshot);
117
134
  if (!queue || !Array.isArray(queue.entries)) {
118
- throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
135
+ throw new StackWatchError("Invalid merge queue response", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
119
136
  }
120
137
  return {
121
138
  entries: queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id),
@@ -123,16 +140,18 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
123
140
  };
124
141
  };
125
142
  const evaluate = (stack, entries) => {
126
- const { attention, issues, currentCandidate, bounce, busy, agents } = stackBlockers(stack, entries, cursor);
143
+ const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
127
144
  if (bounce)
128
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
129
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
130
147
  const held = busy[0];
131
- lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents,
132
- blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
133
- prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
134
- headSha: attention ? attention.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
135
- status: attention ? "attention" : busy.length || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
148
+ const shown = attention ?? named;
149
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
150
+ actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
151
+ blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
152
+ prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
153
+ headSha: shown ? shown.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
154
+ status: attention ? "attention" : busy.length || landGatePending || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
136
155
  ? "in_progress" : "waiting" };
137
156
  };
138
157
  let stack = await enrich(timeoutMs === 0);
@@ -145,8 +164,8 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
145
164
  agents: layer ? layerAgents(layer) : null };
146
165
  let queue = await readQueue(queueRoute, timeoutMs === 0);
147
166
  evaluate(stack, queue.entries);
148
- if (lastEnvelope.status === "attention" || timeoutMs === 0)
149
- return lastEnvelope;
167
+ if (lastEnvelope.status === "attention" || timeoutMs === 0 || terminal)
168
+ return seal(lastEnvelope);
150
169
  while (now() < deadline) {
151
170
  const heldAt = now();
152
171
  const seconds = Math.min(lastEnvelope.busy.length ? STACK_WATCH_BUSY_RECHECK_S : 45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
@@ -162,9 +181,9 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
162
181
  agents: refreshed ? layerAgents(refreshed) : null };
163
182
  transientFailures = 0;
164
183
  evaluate(stack, queue.entries);
165
- if (lastEnvelope.status === "attention")
166
- return lastEnvelope;
167
- opts.onTick?.(lastEnvelope);
184
+ if (lastEnvelope.status === "attention" || terminal)
185
+ return seal(lastEnvelope);
186
+ opts.onTick?.(seal(lastEnvelope));
168
187
  }
169
188
  throw timeout();
170
189
  }
package/dist/ui/ansi.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
3
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
4
+ */
5
+ export declare const BRAND_GREEN: {
6
+ readonly hex: "#5FAF5F";
7
+ readonly index: 77;
8
+ };
9
+ export declare const BRAND_GREEN_SGR: string;
10
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
11
+ export declare const BRAND_FILL_SGR: string;
1
12
  export declare const ansi: {
2
13
  readonly enabled: boolean;
3
14
  green: (t: string) => string;
@@ -7,8 +18,10 @@ export declare const ansi: {
7
18
  yellow: (t: string) => string;
8
19
  gray: (t: string) => string;
9
20
  brightGreen: (t: string) => string;
21
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
22
+ brandFill: (t: string) => string;
10
23
  invert: (t: string) => string;
11
24
  boldGreen: (t: string) => string;
12
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
25
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
13
26
  boldBrightGreen: (t: string) => string;
14
27
  };
package/dist/ui/ansi.js CHANGED
@@ -9,7 +9,25 @@ function computeEnabled() {
9
9
  function wrap(code, text) {
10
10
  if (!computeEnabled())
11
11
  return text;
12
- return `[${code}m${text}`;
12
+ return `\x1b[${code}m${text}\x1b[0m`;
13
+ }
14
+ /**
15
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
16
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
17
+ */
18
+ export const BRAND_GREEN = { hex: "#5FAF5F", index: 77 };
19
+ export const BRAND_GREEN_SGR = `38;5;${BRAND_GREEN.index}`;
20
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
21
+ export const BRAND_FILL_SGR = `${BRAND_GREEN_SGR};48;5;${BRAND_GREEN.index}`;
22
+ function brandGreen(text, bold = false) {
23
+ if (!computeEnabled())
24
+ return text;
25
+ return `\x1b[${bold ? "1;" : ""}${BRAND_GREEN_SGR}m${text}\x1b[0m`;
26
+ }
27
+ function brandFill(text) {
28
+ if (!computeEnabled())
29
+ return text;
30
+ return `\x1b[${BRAND_FILL_SGR}m${text}\x1b[0m`;
13
31
  }
14
32
  export const ansi = {
15
33
  get enabled() {
@@ -21,9 +39,11 @@ export const ansi = {
21
39
  red: (t) => wrap("31", t),
22
40
  yellow: (t) => wrap("33", t),
23
41
  gray: (t) => wrap("90", t),
24
- brightGreen: (t) => wrap("92", t),
42
+ brightGreen: (t) => brandGreen(t),
43
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
44
+ brandFill: (t) => brandFill(t),
25
45
  invert: (t) => wrap("7", t),
26
- boldGreen: (t) => (computeEnabled() ? `${t}` : t),
27
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
28
- boldBrightGreen: (t) => (computeEnabled() ? `${t}` : t),
46
+ boldGreen: (t) => (computeEnabled() ? `\x1b[1;32m${t}\x1b[0m` : t),
47
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
48
+ boldBrightGreen: (t) => brandGreen(t, true),
29
49
  };