mergestorm 0.3.25 → 0.3.26
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 +6 -6
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +2 -1
- package/dist/commands/registry.js +1 -1
- package/dist/commands/stack.d.ts +8 -0
- package/dist/commands/stack.js +68 -7
- package/dist/shell.js +1 -1
- package/dist/stack-blockers.d.ts +28 -1
- package/dist/stack-blockers.js +89 -6
- package/dist/stack-dto.d.ts +4 -0
- package/dist/stack-watch-obligation.d.ts +43 -0
- package/dist/stack-watch-obligation.js +79 -0
- package/dist/stack-watch.d.ts +5 -1
- package/dist/stack-watch.js +34 -17
- package/dist/ui/ansi.d.ts +14 -1
- package/dist/ui/ansi.js +25 -5
- package/dist/ui/banner.d.ts +2 -1
- package/dist/ui/banner.js +23 -47
- package/dist/ui/logo.d.ts +13 -8
- package/dist/ui/logo.js +25 -13
- package/dist/ui/prompt.d.ts +11 -1
- package/dist/ui/prompt.js +92 -9
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +7 -3
package/README.md
CHANGED
|
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
48
|
-
│
|
|
49
|
-
│
|
|
50
|
-
│ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used
|
|
51
|
-
│
|
|
52
|
-
│
|
|
53
|
-
│ New
|
|
48
|
+
│ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.26 │
|
|
49
|
+
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
|
+
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
|
+
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
|
52
|
+
│ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
|
|
53
|
+
│ ▀ New: stack results say the watch ends only at land │
|
|
54
54
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
55
55
|
mergestorm
|
|
56
56
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
package/dist/client/index.d.ts
CHANGED
|
@@ -7,4 +7,5 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFal
|
|
|
7
7
|
export { discoverTrunk } from "../git-stack.js";
|
|
8
8
|
export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel, type StackAgentsBusy } from "../stack-dto.js";
|
|
9
9
|
export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
|
|
10
|
-
export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, type StackAgentBusy, type StackBlocker, type StackLayerAgents, } from "../stack-blockers.js";
|
|
10
|
+
export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, type StackAgentBusy, type StackBlocker, type StackLandGatePending, type StackLayerAgents, type StackRepairHint, } from "../stack-blockers.js";
|
|
11
|
+
export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, type StackWatchDoneReason, type StackWatchNext, type StackWatchNextArgs, type StackWatchObligation, type StackWatchObligationInput, type StackWatchReason, } from "../stack-watch-obligation.js";
|
package/dist/client/index.js
CHANGED
|
@@ -7,4 +7,5 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-enve
|
|
|
7
7
|
export { discoverTrunk } from "../git-stack.js";
|
|
8
8
|
export { BOUNCE_KIND_LABELS, deriveStackAgentsBusy, mergeQueueBounceLabel } from "../stack-dto.js";
|
|
9
9
|
export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, STACK_WATCH_BUSY_RECHECK_S, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
|
|
10
|
-
export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, } from "../stack-blockers.js";
|
|
10
|
+
export { stackBlockers, stackBlockersSummary, sameHead, layerAgents, layerAgentsBusy, conflictLiveParent, stackRepair, } from "../stack-blockers.js";
|
|
11
|
+
export { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchNextArgs, stackWatchObligation, } from "../stack-watch-obligation.js";
|
|
@@ -224,7 +224,7 @@ export const COMMAND_REGISTRY = [
|
|
|
224
224
|
"mergestorm stack reset --force Clear this repo's pre-submit authoring state",
|
|
225
225
|
"mergestorm stack list [--json] List registered stacks",
|
|
226
226
|
"mergestorm stack status <stack-id> [--json] Enriched stack status",
|
|
227
|
-
"mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)",
|
|
227
|
+
"mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait). --json: one document on stdout; timeout notice on stderr, exit 5",
|
|
228
228
|
"mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json] Per-stack policy",
|
|
229
229
|
"mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] Import an open PR chain",
|
|
230
230
|
"mergestorm stack restack <stack-id> Restack stack descendants",
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type StackRepairHint } from "../stack-blockers.js";
|
|
2
|
+
import { type StackWatchObligation } from "../stack-watch-obligation.js";
|
|
1
3
|
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
2
4
|
import { loadConfig } from "../config.js";
|
|
3
5
|
import { createPrs, findOpenPrNumber } from "../gh.js";
|
|
@@ -7,6 +9,11 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
|
7
9
|
import { gitTopLevel } from "../repo-identity.js";
|
|
8
10
|
import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
|
|
9
11
|
import { pollStackWatch } from "../stack-watch.js";
|
|
12
|
+
export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
|
|
13
|
+
export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
|
|
14
|
+
export declare function openStackWatch(stackId: string, opts?: {
|
|
15
|
+
unread?: boolean;
|
|
16
|
+
}): StackWatchObligation;
|
|
10
17
|
/** A registered stack layer that would become the parent of a new layer. */
|
|
11
18
|
export type RegisteredParentHit = {
|
|
12
19
|
stackId: string;
|
|
@@ -140,6 +147,7 @@ export type StackSetDeps = {
|
|
|
140
147
|
};
|
|
141
148
|
/** Human summary lines for a `stack set` write, one per flag. */
|
|
142
149
|
export declare function stackSetSummary(parsed: StackSetArgs): string[];
|
|
150
|
+
export declare function withWatch(body: unknown, watch: StackWatchObligation | null): unknown;
|
|
143
151
|
export declare function cmdStackSet(args: string[], deps?: StackSetDeps): Promise<void>;
|
|
144
152
|
export declare function cmdStack(args: string[], options?: {
|
|
145
153
|
signal?: AbortSignal;
|
package/dist/commands/stack.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { stackBlockersSummary } from "../stack-blockers.js";
|
|
1
|
+
import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
|
|
2
|
+
import { STACK_WATCH_NOT_DONE_SENTENCE, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
|
|
2
3
|
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
|
|
3
4
|
import { loadConfig } from "../config.js";
|
|
4
5
|
import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
|
|
@@ -15,6 +16,26 @@ import { runLineTabsBrowser } from "../ui/line-tabs.js";
|
|
|
15
16
|
import { present } from "../ui/present.js";
|
|
16
17
|
import { canBrowse } from "./browse.js";
|
|
17
18
|
import { openHelpBrowser } from "./help.js";
|
|
19
|
+
export function stackRepairLine(repair) {
|
|
20
|
+
if (!repair)
|
|
21
|
+
return [];
|
|
22
|
+
const files = "files" in repair && repair.files.length ? ` (files: ${repair.files.join(", ")})` : "";
|
|
23
|
+
return [`Repair #${repair.prNumber} ${repair.kind}${files}: ${repair.steps}`];
|
|
24
|
+
}
|
|
25
|
+
export function stackWatchTextLines(watch) {
|
|
26
|
+
if (!watch)
|
|
27
|
+
return [];
|
|
28
|
+
if (watch.done)
|
|
29
|
+
return [watch.message];
|
|
30
|
+
const next = watch.next;
|
|
31
|
+
return [
|
|
32
|
+
...(next ? [`Next: ${next.command} (MCP: stack_wait ${JSON.stringify(next.args)})`] : []),
|
|
33
|
+
STACK_WATCH_NOT_DONE_SENTENCE,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
export function openStackWatch(stackId, opts = {}) {
|
|
37
|
+
return stackWatchObligation({ stackId, terminal: null, unread: opts.unread, freshCursor: true });
|
|
38
|
+
}
|
|
18
39
|
const STACK_USAGE = `usage:
|
|
19
40
|
mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
|
|
20
41
|
mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--rest] [--json]
|
|
@@ -22,6 +43,8 @@ const STACK_USAGE = `usage:
|
|
|
22
43
|
mergestorm stack list [--json]
|
|
23
44
|
mergestorm stack status <stack-id> [--json]
|
|
24
45
|
mergestorm stack wait <stack-id> [--json] [--timeout <s>]
|
|
46
|
+
--json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
|
|
47
|
+
(exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
|
|
25
48
|
mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
|
|
26
49
|
mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
|
|
27
50
|
mergestorm stack restack <stack-id> [--json]
|
|
@@ -780,6 +803,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
780
803
|
stackId: stackId ?? null,
|
|
781
804
|
trunk: data.stack?.trunkBranch ?? meta.trunk,
|
|
782
805
|
chain: data.chain ?? null,
|
|
806
|
+
watch: stackId ? openStackWatch(stackId) : null,
|
|
783
807
|
}, null, 2));
|
|
784
808
|
return;
|
|
785
809
|
}
|
|
@@ -799,6 +823,8 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
799
823
|
summary.push(ansi.dim(" Stack registration returned no stack ID"));
|
|
800
824
|
}
|
|
801
825
|
summary.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
826
|
+
if (stackId)
|
|
827
|
+
summary.push(...stackWatchTextLines(openStackWatch(stackId)).map((line) => ` ${line}`));
|
|
802
828
|
await present("Stack", summary);
|
|
803
829
|
}
|
|
804
830
|
async function cmdStackReset(args) {
|
|
@@ -868,7 +894,11 @@ export async function cmdStackWait(args, deps = {}) {
|
|
|
868
894
|
...envelope,
|
|
869
895
|
...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
|
|
870
896
|
}, null, 2)
|
|
871
|
-
:
|
|
897
|
+
: [
|
|
898
|
+
`Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
|
|
899
|
+
...stackRepairLine(envelope.repair),
|
|
900
|
+
...stackWatchTextLines(envelope.watch),
|
|
901
|
+
].join("\n"));
|
|
872
902
|
try {
|
|
873
903
|
print(await (deps.pollStackWatch ?? pollStackWatch)(cfg, stackId, {
|
|
874
904
|
timeoutMs: timeoutS * 1000,
|
|
@@ -892,6 +922,7 @@ export async function cmdStackWait(args, deps = {}) {
|
|
|
892
922
|
}
|
|
893
923
|
export async function cmdStackStatus(args, deps = {}) {
|
|
894
924
|
const usage = "usage: mergestorm stack status <stack-id> [--json]";
|
|
925
|
+
const asJson = args.includes("--json");
|
|
895
926
|
const positional = args.filter((arg) => arg !== "--json");
|
|
896
927
|
if (positional.length !== 1)
|
|
897
928
|
throw new CommandError(usage, 2, "usage");
|
|
@@ -899,10 +930,25 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
899
930
|
const cfg = await (deps.loadConfig ?? loadConfig)();
|
|
900
931
|
const stack = await (deps.getEnrichedStack ?? getEnrichedStack)(stackId, cfg);
|
|
901
932
|
if (!stack) {
|
|
933
|
+
const watch = stackWatchObligation({ stackId, terminal: "not_found" });
|
|
934
|
+
if (asJson) {
|
|
935
|
+
console.log(JSON.stringify({ error: { code: "not_found", stack_id: stackId }, watch }, null, 2));
|
|
936
|
+
process.exitCode = 1;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
console.log(watch.message);
|
|
902
940
|
throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
|
|
903
941
|
}
|
|
904
|
-
|
|
905
|
-
|
|
942
|
+
const { attention, currentCandidate, repair } = stackBlockers(stack);
|
|
943
|
+
const watch = stackWatchObligation({
|
|
944
|
+
stackId: stack.id,
|
|
945
|
+
terminal: stackTerminalReason(stack),
|
|
946
|
+
cursor: { enrolledHeadSha: currentCandidate?.headSha ?? null },
|
|
947
|
+
attention: attention ? { prNumber: attention.prNumber, blocker: attention.blocker } : null,
|
|
948
|
+
freshCursor: true,
|
|
949
|
+
});
|
|
950
|
+
if (asJson) {
|
|
951
|
+
console.log(JSON.stringify({ ...stack, repair, watch }, null, 2));
|
|
906
952
|
return;
|
|
907
953
|
}
|
|
908
954
|
const lines = formatStackHuman(stack, false);
|
|
@@ -915,6 +961,7 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
915
961
|
(layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
|
|
916
962
|
(layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
|
|
917
963
|
}
|
|
964
|
+
lines.push(...[...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
|
|
918
965
|
await present("Stack status", lines);
|
|
919
966
|
}
|
|
920
967
|
/** Human summary lines for a `stack set` write, one per flag. */
|
|
@@ -932,6 +979,11 @@ export function stackSetSummary(parsed) {
|
|
|
932
979
|
}
|
|
933
980
|
return lines;
|
|
934
981
|
}
|
|
982
|
+
export function withWatch(body, watch) {
|
|
983
|
+
if (!watch || typeof body !== "object" || body === null || Array.isArray(body))
|
|
984
|
+
return body;
|
|
985
|
+
return { ...body, watch };
|
|
986
|
+
}
|
|
935
987
|
export async function cmdStackSet(args, deps = {}) {
|
|
936
988
|
const parsed = parseStackSetArgs(args);
|
|
937
989
|
const cfg = await (deps.loadConfig ?? loadConfig)();
|
|
@@ -940,18 +992,26 @@ export async function cmdStackSet(args, deps = {}) {
|
|
|
940
992
|
...(parsed.autoReview !== undefined ? { autoReviewOverride: parsed.autoReview } : {}),
|
|
941
993
|
...(parsed.autoPatch !== undefined ? { autoPatchOverride: parsed.autoPatch } : {}),
|
|
942
994
|
}, cfg);
|
|
995
|
+
const watch = openStackWatch(parsed.stackId, { unread: true });
|
|
943
996
|
if (parsed.asJson) {
|
|
944
|
-
console.log(JSON.stringify(body, null, 2));
|
|
997
|
+
console.log(JSON.stringify(withWatch(body, watch), null, 2));
|
|
945
998
|
return;
|
|
946
999
|
}
|
|
947
|
-
await present("Stack",
|
|
1000
|
+
await present("Stack", [
|
|
1001
|
+
...stackSetSummary(parsed).map((line) => ansi.brightGreen(` ${line} for ${parsed.stackId}`)),
|
|
1002
|
+
...stackWatchTextLines(watch).map((line) => ` ${line}`),
|
|
1003
|
+
]);
|
|
948
1004
|
}
|
|
949
1005
|
async function cmdStackAdopt(args) {
|
|
950
1006
|
const { owner, repo, prNumber, autoLand, autoReview, autoPatch, asJson } = parseStackAdoptArgs(args);
|
|
951
1007
|
const cfg = await loadConfig();
|
|
952
1008
|
const body = await adoptStack(owner, repo, prNumber, cfg, openPolicyPatch({ autoLand, autoReview, autoPatch }));
|
|
1009
|
+
const adoptedId = typeof body === "object" && body !== null
|
|
1010
|
+
? body.stack?.id
|
|
1011
|
+
: undefined;
|
|
1012
|
+
const watch = typeof adoptedId === "string" && adoptedId ? openStackWatch(adoptedId, { unread: true }) : null;
|
|
953
1013
|
if (asJson) {
|
|
954
|
-
console.log(JSON.stringify(body, null, 2));
|
|
1014
|
+
console.log(JSON.stringify(withWatch(body, watch), null, 2));
|
|
955
1015
|
return;
|
|
956
1016
|
}
|
|
957
1017
|
if (typeof body !== "object" || body === null) {
|
|
@@ -969,6 +1029,7 @@ async function cmdStackAdopt(args) {
|
|
|
969
1029
|
imported.push(ansi.dim(` trunk: ${stack.trunkBranch}`));
|
|
970
1030
|
}
|
|
971
1031
|
imported.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
1032
|
+
imported.push(...stackWatchTextLines(watch).map((line) => ` ${line}`));
|
|
972
1033
|
await present("Stack", imported);
|
|
973
1034
|
}
|
|
974
1035
|
async function cmdStackRestack(args) {
|
package/dist/shell.js
CHANGED
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -27,6 +27,28 @@ export type StackLayerAgents = {
|
|
|
27
27
|
cyclone: boolean;
|
|
28
28
|
};
|
|
29
29
|
};
|
|
30
|
+
export type StackRepairHint = {
|
|
31
|
+
kind: "restack_conflict" | "merge_conflict";
|
|
32
|
+
prNumber: number;
|
|
33
|
+
headSha: string | null;
|
|
34
|
+
branch: string;
|
|
35
|
+
liveParent: string | null;
|
|
36
|
+
files: string[];
|
|
37
|
+
steps: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "ci_failure";
|
|
40
|
+
prNumber: number;
|
|
41
|
+
headSha: string | null;
|
|
42
|
+
branch: string;
|
|
43
|
+
failingCheck: string | null;
|
|
44
|
+
steps: string;
|
|
45
|
+
};
|
|
46
|
+
export type StackLandGatePending = {
|
|
47
|
+
prNumber: number;
|
|
48
|
+
headSha: string | null;
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function landGateIsPending(reason: string | null | undefined): boolean;
|
|
30
52
|
export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
|
|
31
53
|
export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
|
|
32
54
|
export declare function isMgParkBase(branch: string | null | undefined): boolean;
|
|
@@ -34,6 +56,9 @@ export declare function isMgParkBase(branch: string | null | undefined): boolean
|
|
|
34
56
|
export declare function boundDirty(layer: StackLayerDto): boolean;
|
|
35
57
|
export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
36
58
|
export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
|
|
59
|
+
export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
|
|
60
|
+
export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
|
|
61
|
+
export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
|
|
37
62
|
/** Pair-gate attention plus live hard blocks on every other open layer. */
|
|
38
63
|
export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
|
|
39
64
|
attention: StackBlocker | null;
|
|
@@ -45,6 +70,8 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
|
|
|
45
70
|
bounce: MergeQueueEntryDto | undefined;
|
|
46
71
|
busy: StackAgentBusy[];
|
|
47
72
|
agents: StackLayerAgents | null;
|
|
73
|
+
repair: StackRepairHint | null;
|
|
74
|
+
landGatePending: StackLandGatePending | null;
|
|
48
75
|
};
|
|
49
76
|
/** 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;
|
|
77
|
+
export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null): string;
|
package/dist/stack-blockers.js
CHANGED
|
@@ -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
|
}
|
|
@@ -98,8 +102,9 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
98
102
|
if (seam)
|
|
99
103
|
return hardBlock(seam);
|
|
100
104
|
if (stack.unit?.landPrNumber === layer.prNumber) {
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
const landing = stack.unit.landingBlockReason?.trim();
|
|
106
|
+
if (landing && !landGateIsPending(landing))
|
|
107
|
+
return hardBlock(landing);
|
|
103
108
|
if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
|
|
104
109
|
return hardBlock("Tempest failed");
|
|
105
110
|
}
|
|
@@ -115,6 +120,74 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
115
120
|
return none;
|
|
116
121
|
return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
|
|
117
122
|
}
|
|
123
|
+
function isTerminalLayer(layer) {
|
|
124
|
+
return layer.state === "merged" || layer.state === "closed";
|
|
125
|
+
}
|
|
126
|
+
export function conflictLiveParent(stack, layer) {
|
|
127
|
+
const promoted = (candidate) => !!stack.unit?.members?.some((member) => member.prNumber === candidate.prNumber && member.promotedHeadSha?.trim());
|
|
128
|
+
const sibling = stack.layers
|
|
129
|
+
.filter((candidate) => candidate.prNumber > 0 && candidate.position < layer.position &&
|
|
130
|
+
!isTerminalLayer(candidate) && !promoted(candidate))
|
|
131
|
+
.sort((a, b) => b.position - a.position)[0];
|
|
132
|
+
if (sibling)
|
|
133
|
+
return sibling.branch;
|
|
134
|
+
const retired = new Set([
|
|
135
|
+
...stack.layers.filter((candidate) => isTerminalLayer(candidate) || promoted(candidate)).map((candidate) => candidate.branch),
|
|
136
|
+
...(stack.unit?.members ?? []).filter((member) => member.promotedHeadSha?.trim()).map((member) => member.branch),
|
|
137
|
+
]);
|
|
138
|
+
const usable = (branch) => !!branch?.trim() && !isMgParkBase(branch) && !retired.has(branch) && branch !== layer.branch;
|
|
139
|
+
for (const branch of [layer.restackError?.from?.branch, layer.parentBranch, stack.unit?.branch, stack.trunkBranch]) {
|
|
140
|
+
if (usable(branch))
|
|
141
|
+
return branch;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function conflictFiles(layer, bounce) {
|
|
146
|
+
const paths = bounce?.bounceDetail?.conflictPaths?.filter((path) => path.trim());
|
|
147
|
+
if (paths?.length)
|
|
148
|
+
return [...paths];
|
|
149
|
+
const detail = layer.restackError?.kind === "rebase_conflict" ? layer.restackError.detail : layer.conflictDetail;
|
|
150
|
+
const listed = detail?.match(/^conflict in (.+)$/)?.[1];
|
|
151
|
+
return listed ? listed.split(", ").map((path) => path.trim()).filter(Boolean) : [];
|
|
152
|
+
}
|
|
153
|
+
export function conflictRepairSteps(branch, liveParent) {
|
|
154
|
+
if (!liveParent) {
|
|
155
|
+
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.`;
|
|
156
|
+
}
|
|
157
|
+
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.`;
|
|
158
|
+
}
|
|
159
|
+
export function stackRepair(stack, attention, layer, bounce) {
|
|
160
|
+
if (!attention || !layer || attention.prNumber !== layer.prNumber)
|
|
161
|
+
return null;
|
|
162
|
+
if (stack.unit && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch)
|
|
163
|
+
return null;
|
|
164
|
+
const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
|
|
165
|
+
const mergeConflict = attention.blocker.startsWith("Merge conflicts");
|
|
166
|
+
if (restackConflict || mergeConflict) {
|
|
167
|
+
const liveParent = conflictLiveParent(stack, layer);
|
|
168
|
+
return {
|
|
169
|
+
kind: restackConflict ? "restack_conflict" : "merge_conflict",
|
|
170
|
+
prNumber: layer.prNumber,
|
|
171
|
+
headSha: layer.headSha ?? null,
|
|
172
|
+
branch: layer.branch,
|
|
173
|
+
liveParent,
|
|
174
|
+
files: conflictFiles(layer, bounce),
|
|
175
|
+
steps: conflictRepairSteps(layer.branch, liveParent),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
|
|
179
|
+
const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
|
|
180
|
+
return {
|
|
181
|
+
kind: "ci_failure",
|
|
182
|
+
prNumber: layer.prNumber,
|
|
183
|
+
headSha: layer.headSha ?? null,
|
|
184
|
+
branch: layer.branch,
|
|
185
|
+
failingCheck,
|
|
186
|
+
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.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
118
191
|
/** Pair-gate attention plus live hard blocks on every other open layer. */
|
|
119
192
|
export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
120
193
|
const candidate = currentLayer(stack);
|
|
@@ -155,16 +228,26 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
155
228
|
busy.push({ prNumber: attention.prNumber, headSha: attention.headSha, agent, blocker: attention.blocker });
|
|
156
229
|
}
|
|
157
230
|
}
|
|
231
|
+
const landing = stack.unit?.landingBlockReason?.trim();
|
|
232
|
+
const landLayer = stack.unit?.landPrNumber != null && !stack.archivedAt
|
|
233
|
+
? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber)
|
|
234
|
+
: undefined;
|
|
235
|
+
const landGatePending = landLayer && landing && landGateIsPending(landing)
|
|
236
|
+
? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
|
|
237
|
+
: null;
|
|
158
238
|
if (busy.length)
|
|
159
|
-
return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents };
|
|
160
|
-
return { attention, issues, currentCandidate, bounce, busy, agents };
|
|
239
|
+
return { attention: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
|
|
240
|
+
return { attention, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
|
|
161
241
|
}
|
|
162
242
|
/** Compact contract text shared by status and wait summaries. */
|
|
163
|
-
export function stackBlockersSummary(attention, issues, busy = []) {
|
|
243
|
+
export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null) {
|
|
164
244
|
const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
|
|
165
245
|
const working = busy.length
|
|
166
246
|
? ` · held: #${busy[0].prNumber} ${busy[0].blocker} while ${busy.map((entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews").join(" and ")}`
|
|
167
247
|
: "";
|
|
168
248
|
const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
|
|
169
|
-
|
|
249
|
+
const gate = landGatePending && !attention
|
|
250
|
+
? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
|
|
251
|
+
: "";
|
|
252
|
+
return blocked + working + gate + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
|
|
170
253
|
}
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|
package/dist/stack-watch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type StackAgentBusy, type StackBlocker, type StackLayerAgents } from "./stack-blockers.js";
|
|
1
|
+
import { type StackAgentBusy, type StackBlocker, 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";
|
|
@@ -28,6 +29,9 @@ export type StackWatchEnvelope = {
|
|
|
28
29
|
assessment: "available" | "unavailable";
|
|
29
30
|
busy: StackAgentBusy[];
|
|
30
31
|
agents: StackLayerAgents | null;
|
|
32
|
+
repair: StackRepairHint | null;
|
|
33
|
+
landGatePending: StackLandGatePending | null;
|
|
34
|
+
watch: StackWatchObligation;
|
|
31
35
|
};
|
|
32
36
|
export type PollStackWatchOptions = {
|
|
33
37
|
timeoutMs?: number;
|
package/dist/stack-watch.js
CHANGED
|
@@ -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: [], 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: [],
|
|
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,16 @@ 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, 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,
|
|
148
|
+
lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
|
|
132
149
|
blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
|
|
133
150
|
prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
|
|
134
151
|
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))
|
|
152
|
+
status: attention ? "attention" : busy.length || landGatePending || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
|
|
136
153
|
? "in_progress" : "waiting" };
|
|
137
154
|
};
|
|
138
155
|
let stack = await enrich(timeoutMs === 0);
|
|
@@ -145,8 +162,8 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
145
162
|
agents: layer ? layerAgents(layer) : null };
|
|
146
163
|
let queue = await readQueue(queueRoute, timeoutMs === 0);
|
|
147
164
|
evaluate(stack, queue.entries);
|
|
148
|
-
if (lastEnvelope.status === "attention" || timeoutMs === 0)
|
|
149
|
-
return lastEnvelope;
|
|
165
|
+
if (lastEnvelope.status === "attention" || timeoutMs === 0 || terminal)
|
|
166
|
+
return seal(lastEnvelope);
|
|
150
167
|
while (now() < deadline) {
|
|
151
168
|
const heldAt = now();
|
|
152
169
|
const seconds = Math.min(lastEnvelope.busy.length ? STACK_WATCH_BUSY_RECHECK_S : 45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
|
|
@@ -162,9 +179,9 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
162
179
|
agents: refreshed ? layerAgents(refreshed) : null };
|
|
163
180
|
transientFailures = 0;
|
|
164
181
|
evaluate(stack, queue.entries);
|
|
165
|
-
if (lastEnvelope.status === "attention")
|
|
166
|
-
return lastEnvelope;
|
|
167
|
-
opts.onTick?.(lastEnvelope);
|
|
182
|
+
if (lastEnvelope.status === "attention" || terminal)
|
|
183
|
+
return seal(lastEnvelope);
|
|
184
|
+
opts.onTick?.(seal(lastEnvelope));
|
|
168
185
|
}
|
|
169
186
|
throw timeout();
|
|
170
187
|
}
|
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
|
|
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
|
|
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) =>
|
|
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() ?
|
|
27
|
-
/** Prompt / mark ink. Never 32
|
|
28
|
-
boldBrightGreen: (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
|
};
|
package/dist/ui/banner.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type BannerDensity = "full" | "compact" | "mini" | "nano";
|
|
|
14
14
|
export type BannerVariant = boolean | "mini" | "nano";
|
|
15
15
|
/** Handle returned by {@link printBannerHeader}; `rows` reflows for a resize. */
|
|
16
16
|
export type BannerHandle = {
|
|
17
|
-
rows(columns?: number, variant?: BannerVariant): string[];
|
|
17
|
+
rows(columns?: number, variant?: BannerVariant, frame?: number): string[];
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
20
|
* The whole welcome panel as rows, reflowed to `columns`. Every row fits in
|
|
@@ -24,6 +24,7 @@ export type BannerHandle = {
|
|
|
24
24
|
export declare function buildBannerRows(state: BannerState, columns?: number, opts?: {
|
|
25
25
|
compact?: boolean;
|
|
26
26
|
density?: BannerDensity;
|
|
27
|
+
frame?: number;
|
|
27
28
|
}): string[];
|
|
28
29
|
/**
|
|
29
30
|
* Print the welcome panel and return a handle whose `rows()` reflows the
|
package/dist/ui/banner.js
CHANGED
|
@@ -3,8 +3,8 @@ import { CommandError } from "../errors.js";
|
|
|
3
3
|
import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
|
|
4
4
|
import { cliVersion } from "../version.js";
|
|
5
5
|
import { ansi } from "./ansi.js";
|
|
6
|
-
import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
|
|
7
|
-
import {
|
|
6
|
+
import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, terminalColumns, visibleWidth, } from "./box.js";
|
|
7
|
+
import { paintSpriteRow, TORNADO_LOGO, TORNADO_LOGO_WIDTH } from "./logo.js";
|
|
8
8
|
import { formatResetLabel, usageBar } from "./usage.js";
|
|
9
9
|
const WORDMARK = `
|
|
10
10
|
__ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
|
|
@@ -108,6 +108,9 @@ const WHATS_NEW = {
|
|
|
108
108
|
"0.3.25": [
|
|
109
109
|
"stack wait holds a PR while Cyclone or Vortex works",
|
|
110
110
|
],
|
|
111
|
+
"0.3.26": [
|
|
112
|
+
"stack results say the watch ends only at land",
|
|
113
|
+
],
|
|
111
114
|
};
|
|
112
115
|
function densityFromVariant(variant) {
|
|
113
116
|
if (variant === "mini" || variant === "nano")
|
|
@@ -199,57 +202,30 @@ export function buildBannerRows(state, columns, opts) {
|
|
|
199
202
|
const contentCells = boxed
|
|
200
203
|
? Math.max(20, outer - 4)
|
|
201
204
|
: Math.max(20, terminalColumns(80, columns) - 1);
|
|
202
|
-
const
|
|
203
|
-
const
|
|
205
|
+
const markLines = TORNADO_LOGO;
|
|
206
|
+
const markWidth = TORNADO_LOGO_WIDTH;
|
|
207
|
+
const gap = 2;
|
|
208
|
+
const rightCells = Math.max(8, contentCells - markWidth - gap);
|
|
204
209
|
const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
|
|
205
|
-
const
|
|
210
|
+
const clipRight = (line) => visibleWidth(line) > rightCells ? padVisible(line, rightCells) : line;
|
|
206
211
|
const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
|
|
207
|
-
|
|
208
|
-
const one = `${mark(0)}${title} ${statusLine(state)}`;
|
|
209
|
-
return [clip(one)];
|
|
210
|
-
}
|
|
211
|
-
if (density === "mini") {
|
|
212
|
-
// Short pane: mark + who you are, then the same cues as the full home.
|
|
213
|
-
const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
|
|
214
|
-
const line1 = state.me && !state.keyInvalid
|
|
215
|
-
? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
|
|
216
|
-
: clip(mark(1));
|
|
217
|
-
const room = contentCells - indent;
|
|
218
|
-
const blurb = tagline(room);
|
|
219
|
-
const tips = tipsLine(room);
|
|
220
|
-
const fresh = whatsNewLine(state.version, room);
|
|
221
|
-
return [
|
|
222
|
-
line0,
|
|
223
|
-
line1,
|
|
224
|
-
...(blurb ? [clip(`${pad}${blurb}`)] : []),
|
|
225
|
-
...(tips ? [clip(`${pad}${tips}`)] : []),
|
|
226
|
-
...(fresh ? [clip(`${pad}${fresh}`)] : []),
|
|
227
|
-
];
|
|
228
|
-
}
|
|
229
|
-
const content = [
|
|
230
|
-
clip(`${pad}${title}`),
|
|
231
|
-
clip(`${mark(0)}${statusLine(state)}`),
|
|
232
|
-
];
|
|
212
|
+
const right = [clipRight(title), clipRight(statusLine(state))];
|
|
233
213
|
if (state.me && !state.keyInvalid) {
|
|
234
|
-
|
|
214
|
+
right.push(clipRight(usageLine(state.me, rightCells, 0)));
|
|
235
215
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const room = contentCells - indent;
|
|
240
|
-
const blurb = tagline(room);
|
|
241
|
-
const tips = tipsLine(room);
|
|
242
|
-
const fresh = whatsNewLine(state.version, room);
|
|
216
|
+
const blurb = tagline(rightCells);
|
|
217
|
+
const tips = tipsLine(rightCells);
|
|
218
|
+
const fresh = whatsNewLine(state.version, rightCells);
|
|
243
219
|
if (blurb)
|
|
244
|
-
|
|
220
|
+
right.push(clipRight(blurb));
|
|
245
221
|
if (tips)
|
|
246
|
-
|
|
222
|
+
right.push(clipRight(tips));
|
|
247
223
|
if (fresh)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
224
|
+
right.push(clipRight(fresh));
|
|
225
|
+
const markCol = markLines.map((line) => padVisible(paintSpriteRow(line), markWidth));
|
|
226
|
+
const content = sideBySide(markCol, density === "nano" ? right.slice(0, 3) : right, markWidth, gap).map(clip);
|
|
227
|
+
if (density === "mini" || density === "nano")
|
|
228
|
+
return content;
|
|
253
229
|
if (boxed) {
|
|
254
230
|
return [...roundedBox(content, { width: outer, padding: 1 })];
|
|
255
231
|
}
|
|
@@ -285,7 +261,7 @@ export async function printBannerHeader(opts) {
|
|
|
285
261
|
}
|
|
286
262
|
}
|
|
287
263
|
return {
|
|
288
|
-
rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
|
|
264
|
+
rows: (columns, variant, frame) => buildBannerRows(state, columns, { density: densityFromVariant(variant), frame }),
|
|
289
265
|
};
|
|
290
266
|
}
|
|
291
267
|
export function shellPrompt(_loggedIn) {
|
package/dist/ui/logo.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The home mark: a tornado drawn as five horizontal bars and one square tail,
|
|
3
|
+
* each an upper-half band (▀). One column is one bar-height wide; half-column
|
|
4
|
+
* ends use the upper quadrants (▝ ▘) so every edge lands where the source art
|
|
5
|
+
* puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
|
|
4
6
|
*/
|
|
5
|
-
export declare const STORM_MARK: string[];
|
|
6
|
-
/** Widest row of the mark, in cells (every mark glyph is single-cell). */
|
|
7
|
-
export declare const STORM_MARK_WIDTH: number;
|
|
8
|
-
/** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
|
|
9
7
|
export declare const TORNADO_LOGO: string[];
|
|
10
|
-
/**
|
|
8
|
+
/** Single idle frame. Kept for callers that index frames. */
|
|
9
|
+
export declare const TORNADO_FRAMES: string[][];
|
|
10
|
+
/** Every sprite glyph is one column. */
|
|
11
11
|
export declare const TORNADO_LOGO_WIDTH: number;
|
|
12
|
-
/** @deprecated Compact home is the full two-row mark. */
|
|
13
12
|
export declare const TORNADO_LOGO_COMPACT: string[];
|
|
13
|
+
/** @deprecated Use {@link TORNADO_LOGO}. */
|
|
14
|
+
export declare const STORM_MARK: string[];
|
|
15
|
+
/** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
|
|
16
|
+
export declare const STORM_MARK_WIDTH: number;
|
|
17
|
+
/** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
|
|
18
|
+
export declare function paintSpriteRow(row: string): string;
|
package/dist/ui/logo.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import { ansi } from "./ansi.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
3
|
+
* The home mark: a tornado drawn as five horizontal bars and one square tail,
|
|
4
|
+
* each an upper-half band (▀). One column is one bar-height wide; half-column
|
|
5
|
+
* ends use the upper quadrants (▝ ▘) so every edge lands where the source art
|
|
6
|
+
* puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
|
|
4
7
|
*/
|
|
5
|
-
export const
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
+
export const TORNADO_LOGO = [
|
|
9
|
+
" ▀▀▀▀▀▀▀▀▀▀▘",
|
|
10
|
+
"▀▀▀▀▀▀▀▀▀ ",
|
|
11
|
+
" ▀▀▀▀▀▀ ",
|
|
12
|
+
" ▝▀▀▀▀▀ ",
|
|
13
|
+
" ▝▀▀▘ ",
|
|
14
|
+
" ▀ ",
|
|
8
15
|
];
|
|
9
|
-
/**
|
|
10
|
-
export const
|
|
11
|
-
/**
|
|
12
|
-
export const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/** Single idle frame. Kept for callers that index frames. */
|
|
17
|
+
export const TORNADO_FRAMES = [TORNADO_LOGO];
|
|
18
|
+
/** Every sprite glyph is one column. */
|
|
19
|
+
export const TORNADO_LOGO_WIDTH = Math.max(...TORNADO_LOGO.map((line) => [...line].length));
|
|
20
|
+
export const TORNADO_LOGO_COMPACT = TORNADO_LOGO;
|
|
21
|
+
/** @deprecated Use {@link TORNADO_LOGO}. */
|
|
22
|
+
export const STORM_MARK = TORNADO_LOGO;
|
|
23
|
+
/** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
|
|
24
|
+
export const STORM_MARK_WIDTH = TORNADO_LOGO_WIDTH;
|
|
25
|
+
/** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
|
|
26
|
+
export function paintSpriteRow(row) {
|
|
27
|
+
return row.replace(/[^ ]+/g, (run) => ansi.brightGreen(run));
|
|
28
|
+
}
|
package/dist/ui/prompt.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface AskLineOptions {
|
|
|
23
23
|
* resize cannot desync CUU counts. Pass `true` for compact, `"mini"` /
|
|
24
24
|
* `"nano"` for a short pane that still shows the mark.
|
|
25
25
|
*/
|
|
26
|
-
header?: (columns?: number, variant?: boolean | "mini" | "nano") => string[];
|
|
26
|
+
header?: (columns?: number, variant?: boolean | "mini" | "nano", frame?: number) => string[];
|
|
27
27
|
}
|
|
28
28
|
/** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
|
|
29
29
|
export declare class PromptClosedError extends Error {
|
|
@@ -47,6 +47,16 @@ export declare function promptClearOverlay(boxed: boolean): string;
|
|
|
47
47
|
* frame's row count is unknowable after the terminal rewraps.
|
|
48
48
|
*/
|
|
49
49
|
export declare const HEADER_REDRAW_PREFIX = "\u001B[H\u001B[0J";
|
|
50
|
+
export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
|
|
51
|
+
export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
|
|
52
|
+
export type SpriteRect = {
|
|
53
|
+
col: number;
|
|
54
|
+
row: number;
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
};
|
|
58
|
+
/** SGR coordinates and the rectangle are both 1-based. */
|
|
59
|
+
export declare function hitTestSprite(col: number, row: number, rect: SpriteRect): boolean;
|
|
50
60
|
export declare const DROPDOWN_MAX_COMMAND_ROWS = 8;
|
|
51
61
|
export declare function formatSlashLabel(spec: CommandSpec): string;
|
|
52
62
|
export declare function specMatchesTerm(spec: CommandSpec, term: string): boolean;
|
package/dist/ui/prompt.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PassThrough } from "node:stream";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1
3
|
import * as readline from "node:readline";
|
|
2
4
|
import { createInterface } from "node:readline/promises";
|
|
3
5
|
import { stdin, stdout } from "node:process";
|
|
@@ -37,6 +39,13 @@ export function promptClearOverlay(boxed) {
|
|
|
37
39
|
* frame's row count is unknowable after the terminal rewraps.
|
|
38
40
|
*/
|
|
39
41
|
export const HEADER_REDRAW_PREFIX = `\u001b[H${clearDown}`;
|
|
42
|
+
export const MOUSE_ENABLE = "\u001b[?1000h\u001b[?1006h";
|
|
43
|
+
export const MOUSE_DISABLE = "\u001b[?1000l\u001b[?1006l";
|
|
44
|
+
/** SGR coordinates and the rectangle are both 1-based. */
|
|
45
|
+
export function hitTestSprite(col, row, rect) {
|
|
46
|
+
return col >= rect.col && col < rect.col + rect.width &&
|
|
47
|
+
row >= rect.row && row < rect.row + rect.height;
|
|
48
|
+
}
|
|
40
49
|
export const DROPDOWN_MAX_COMMAND_ROWS = 8;
|
|
41
50
|
export function formatSlashLabel(spec) {
|
|
42
51
|
const extra = spec.aliases?.length
|
|
@@ -171,9 +180,10 @@ export function fitHomeFrame(params) {
|
|
|
171
180
|
: params.miniHeader?.length
|
|
172
181
|
? params.miniHeader
|
|
173
182
|
: [];
|
|
183
|
+
const header = fallback.slice(0, Math.max(0, rows - 1));
|
|
174
184
|
return {
|
|
175
|
-
header
|
|
176
|
-
reserve: params.dockBottom ? Math.max(0, rows -
|
|
185
|
+
header,
|
|
186
|
+
reserve: params.dockBottom ? Math.max(0, rows - header.length - 1) : 0,
|
|
177
187
|
boxed: false,
|
|
178
188
|
hasPrompt: false,
|
|
179
189
|
hasHint: false,
|
|
@@ -338,7 +348,72 @@ export async function askLine(opts) {
|
|
|
338
348
|
let exitHint = false;
|
|
339
349
|
const ctrlCExit = new CtrlCExitGate();
|
|
340
350
|
const wasRaw = Boolean(input.isRaw);
|
|
341
|
-
|
|
351
|
+
// Decode keys from a private stream after removing mouse reports. Readline
|
|
352
|
+
// otherwise emits the numeric tail of SGR reports as ordinary typing.
|
|
353
|
+
// Never listen for `keypress` on `input` itself: once any TUI has called
|
|
354
|
+
// emitKeypressEvents(stdin), stdin decodes keys too and every character
|
|
355
|
+
// (and every mouse report) would land in the buffer twice.
|
|
356
|
+
const keys = new PassThrough();
|
|
357
|
+
const decoder = new StringDecoder("utf8");
|
|
358
|
+
let pending = "";
|
|
359
|
+
let escapeTimer;
|
|
360
|
+
readline.emitKeypressEvents(keys);
|
|
361
|
+
keys.on("keypress", onKeypress);
|
|
362
|
+
function onData(chunk) {
|
|
363
|
+
if (finished)
|
|
364
|
+
return;
|
|
365
|
+
if (escapeTimer)
|
|
366
|
+
clearTimeout(escapeTimer);
|
|
367
|
+
pending += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
368
|
+
while (pending && !finished) {
|
|
369
|
+
const start = pending.indexOf("\u001b");
|
|
370
|
+
if (start !== 0) {
|
|
371
|
+
const end = start < 0 ? pending.length : start;
|
|
372
|
+
const text = pending.slice(0, end);
|
|
373
|
+
pending = pending.slice(end);
|
|
374
|
+
keys.write(text);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (pending.startsWith("\u001b[M")) {
|
|
378
|
+
if (pending.length < 6)
|
|
379
|
+
return;
|
|
380
|
+
pending = pending.slice(6);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const mouse = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])/.exec(pending);
|
|
384
|
+
if (mouse) {
|
|
385
|
+
pending = pending.slice(mouse[0].length);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const partialMouse = /^\u001b\[<\d*(?:;\d*){0,2}/.exec(pending);
|
|
389
|
+
if (partialMouse && pending.length > partialMouse[0].length) {
|
|
390
|
+
pending = pending.slice(partialMouse[0].length);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const csi = /^\u001b\[[0-?]*[ -/]*[@-~]/.exec(pending);
|
|
394
|
+
const ss3 = /^\u001bO[ -~]/.exec(pending);
|
|
395
|
+
if (csi || ss3) {
|
|
396
|
+
const sequence = csi?.[0] ?? ss3[0];
|
|
397
|
+
pending = pending.slice(sequence.length);
|
|
398
|
+
keys.write(sequence);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (pending === "\u001b" ||
|
|
402
|
+
/^\u001b(?:\[[0-?]*[ -/]*|O?)$/.test(pending)) {
|
|
403
|
+
// Keep partial mouse reports across chunks. A lone Escape still works.
|
|
404
|
+
if (!pending.startsWith("\u001b[<")) {
|
|
405
|
+
escapeTimer = setTimeout(() => {
|
|
406
|
+
const text = pending;
|
|
407
|
+
pending = "";
|
|
408
|
+
keys.write(text);
|
|
409
|
+
}, 50);
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
keys.write(pending[0]);
|
|
414
|
+
pending = pending.slice(1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
342
417
|
input.setRawMode(true);
|
|
343
418
|
input.resume();
|
|
344
419
|
function currentDropdown() {
|
|
@@ -408,9 +483,8 @@ export async function askLine(opts) {
|
|
|
408
483
|
// Never write more rows than the pane — that scrolls the welcome
|
|
409
484
|
// tail (tips / what's new) into view and looks like a broken frame.
|
|
410
485
|
if (frame.rows.length > roomRows) {
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
frame.rows = keep;
|
|
486
|
+
const shift = frame.rows.length - roomRows;
|
|
487
|
+
frame.rows = frame.rows.slice(shift);
|
|
414
488
|
frame.inputRow = Math.max(0, frame.inputRow - shift);
|
|
415
489
|
}
|
|
416
490
|
// Home screen owns the pane: always origin + ED 0. Relative CUU after
|
|
@@ -442,7 +516,11 @@ export async function askLine(opts) {
|
|
|
442
516
|
paint();
|
|
443
517
|
}
|
|
444
518
|
function cleanup() {
|
|
445
|
-
|
|
519
|
+
if (escapeTimer)
|
|
520
|
+
clearTimeout(escapeTimer);
|
|
521
|
+
input.removeListener("data", onData);
|
|
522
|
+
keys.removeListener("keypress", onKeypress);
|
|
523
|
+
keys.destroy();
|
|
446
524
|
output.removeListener("resize", onResize);
|
|
447
525
|
input.setRawMode(wasRaw);
|
|
448
526
|
// emitKeypressEvents leaves stdin flowing; pause it so an idle shell
|
|
@@ -599,8 +677,13 @@ export async function askLine(opts) {
|
|
|
599
677
|
finish(null, err);
|
|
600
678
|
}
|
|
601
679
|
}
|
|
602
|
-
input.on("
|
|
680
|
+
input.on("data", onData);
|
|
603
681
|
output.on("resize", onResize);
|
|
604
|
-
|
|
682
|
+
try {
|
|
683
|
+
render();
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
finish(null, err);
|
|
687
|
+
}
|
|
605
688
|
});
|
|
606
689
|
}
|
package/package.json
CHANGED
|
@@ -7,6 +7,10 @@ description: Watch stacks explicitly named by the human while Auto land is on, a
|
|
|
7
7
|
|
|
8
8
|
Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`. Use local checkout, test, and Git operations to make the patch. Auto land owns landing.
|
|
9
9
|
|
|
10
|
+
## Not done until landed
|
|
11
|
+
|
|
12
|
+
A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop.
|
|
13
|
+
|
|
10
14
|
## Scope and enrollment
|
|
11
15
|
|
|
12
16
|
Watch only stacks the human named. If no unambiguous stack ID is available, ask the human to identify it; do not discover and enroll other stacks. Keep a separate cursor for each named stack.
|
|
@@ -15,9 +19,9 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
|
|
|
15
19
|
|
|
16
20
|
## Watch and patch
|
|
17
21
|
|
|
18
|
-
1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument.
|
|
19
|
-
2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
|
|
20
|
-
3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy. A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. While `busy[]` holds a PR it can hide a blocker no agent will clear, such as a Draft PR. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off, and do not turn Auto land off or on, to end the wait. If the remote head moves while you wait, stop and tell the human.
|
|
22
|
+
1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument. `watch.next.args` already holds them in that shape, with `timeout_s: 45`; passing it unchanged is the same call.
|
|
23
|
+
2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, call `stack_wait` once more with the same cursor; if it fails again, stop and report the reason and that the stack is not landed. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
|
|
24
|
+
3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy. A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. While `busy[]` holds a PR it can hide a blocker no agent will clear, such as a Draft PR. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off, and do not turn Auto land off or on, to end the wait. If the remote head moves while you wait, stop and tell the human.
|
|
21
25
|
4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
|
|
22
26
|
5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`.
|
|
23
27
|
6. Recheck Auto land and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
|