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.
- package/README.md +13 -8
- package/dist/api.d.ts +16 -8
- package/dist/api.js +16 -23
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/commands/dismiss.js +4 -1
- package/dist/commands/login.d.ts +4 -1
- package/dist/commands/login.js +5 -3
- package/dist/commands/registry.js +1 -1
- package/dist/commands/review-client.d.ts +1 -2
- package/dist/commands/review-client.js +24 -3
- package/dist/commands/stack.d.ts +9 -1
- package/dist/commands/stack.js +99 -42
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -1
- package/dist/errors.d.ts +1 -1
- package/dist/git-stack.d.ts +2 -0
- package/dist/git-stack.js +15 -0
- package/dist/repo-identity.d.ts +0 -3
- package/dist/repo-identity.js +0 -23
- package/dist/server-publish.d.ts +14 -1
- package/dist/server-publish.js +37 -9
- package/dist/stack-blockers.d.ts +4 -17
- package/dist/stack-blockers.js +93 -26
- package/dist/stack-dto.d.ts +2 -0
- package/dist/stack-meta.d.ts +0 -2
- package/dist/stack-meta.js +3 -81
- package/dist/stack-watch-loop.js +4 -1
- package/dist/stack-watch-obligation.d.ts +4 -1
- package/dist/stack-watch-obligation.js +7 -4
- package/dist/stack-watch.js +2 -1
- package/dist/ui/banner.js +6 -0
- package/package.json +9 -2
- package/skill/SKILL.md +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +11 -5
- package/skill/mergestorm-pr-loop/SKILL.md +7 -3
package/dist/stack-blockers.js
CHANGED
|
@@ -72,10 +72,26 @@ function vortexFindingsRestAtHead(layer) {
|
|
|
72
72
|
function vortexChangesRequestedAtHead(layer) {
|
|
73
73
|
return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
|
|
74
74
|
}
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) &&
|
|
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) &&
|
|
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 (
|
|
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
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
|
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) {
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -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;
|
package/dist/stack-meta.d.ts
CHANGED
|
@@ -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;
|
package/dist/stack-meta.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
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);
|
package/dist/stack-watch-loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
return
|
|
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.");
|
package/dist/stack-watch.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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":
|
|
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.
|