@flumecode/runner 0.22.0 → 0.23.1
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/dist/cli.js +1109 -62
- package/dist/mcp-stdio.js +33 -11
- package/package.json +7 -1
- package/skills-plugin/skills/implement-plan/SKILL.md +1 -0
- package/skills-plugin/skills/preview-ui/SKILL.md +127 -0
- package/skills-plugin/skills/request-to-plan/SKILL.md +10 -12
- package/skills-plugin/skills/revise-implementation/SKILL.md +4 -3
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,653 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/version.ts
|
|
13
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
function readVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
19
|
+
return pkg.version ?? "unknown";
|
|
20
|
+
} catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function compareVersions(a, b) {
|
|
25
|
+
const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
26
|
+
const pa = parse(a);
|
|
27
|
+
const pb = parse(b);
|
|
28
|
+
const len = Math.max(pa.length, pb.length);
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
31
|
+
if (diff !== 0) return diff;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
var RUNNER_VERSION, RUNNER_VERSION_HEADER, RUNNER_MIN_VERSION_HEADER, RUNNER_LATEST_VERSION_HEADER;
|
|
36
|
+
var init_version = __esm({
|
|
37
|
+
"src/version.ts"() {
|
|
38
|
+
"use strict";
|
|
39
|
+
RUNNER_VERSION = readVersion();
|
|
40
|
+
RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
41
|
+
RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
|
|
42
|
+
RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// src/tui-api.ts
|
|
47
|
+
function authHeaders(cfg) {
|
|
48
|
+
return {
|
|
49
|
+
Authorization: `Bearer ${cfg.apiToken ?? ""}`,
|
|
50
|
+
"x-flumecode-runner-version": RUNNER_VERSION
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function safeText2(res) {
|
|
54
|
+
try {
|
|
55
|
+
return await res.text();
|
|
56
|
+
} catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function get(cfg, path) {
|
|
61
|
+
const res = await fetch(cfg.serverUrl + path, { headers: authHeaders(cfg) });
|
|
62
|
+
if (!res.ok) throw new Error(await safeText2(res));
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
async function post(cfg, path, body) {
|
|
66
|
+
const res = await fetch(cfg.serverUrl + path, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify(body)
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw new Error(await safeText2(res));
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
var listRepositories, listAgents, listRequests, getRequest, createRequest, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
|
|
75
|
+
var init_tui_api = __esm({
|
|
76
|
+
"src/tui-api.ts"() {
|
|
77
|
+
"use strict";
|
|
78
|
+
init_version();
|
|
79
|
+
listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
|
|
80
|
+
listAgents = (cfg) => get(cfg, "/api/cli/agents").then((r) => r.agents);
|
|
81
|
+
listRequests = (cfg, repoId) => get(cfg, `/api/cli/repositories/${repoId}/requests`).then(
|
|
82
|
+
(r) => r.requests
|
|
83
|
+
);
|
|
84
|
+
getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
|
|
85
|
+
createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
|
|
86
|
+
answerWidgets = (cfg, messageId, payload) => post(cfg, `/api/cli/messages/${messageId}/answer`, payload);
|
|
87
|
+
acceptPlan = (cfg, messageId, target, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target, mergeBranch });
|
|
88
|
+
startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
|
|
89
|
+
listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
|
|
90
|
+
listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// src/tui/usePoll.ts
|
|
95
|
+
import { useState, useEffect, useRef } from "react";
|
|
96
|
+
function usePoll(fetcher, intervalMs) {
|
|
97
|
+
const [data, setData] = useState(null);
|
|
98
|
+
const fetcherRef = useRef(fetcher);
|
|
99
|
+
fetcherRef.current = fetcher;
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
function poll() {
|
|
103
|
+
fetcherRef.current().then((result) => {
|
|
104
|
+
if (!cancelled) setData(result);
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
poll();
|
|
109
|
+
const id = setInterval(poll, intervalMs);
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
clearInterval(id);
|
|
113
|
+
};
|
|
114
|
+
}, [intervalMs]);
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
var init_usePoll = __esm({
|
|
118
|
+
"src/tui/usePoll.ts"() {
|
|
119
|
+
"use strict";
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// src/tui/StatusBar.tsx
|
|
124
|
+
import { Box, Text } from "ink";
|
|
125
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
126
|
+
function StatusBar({ jobs, runners }) {
|
|
127
|
+
const runningJobs = jobs?.filter((j) => j.status === "running").length ?? 0;
|
|
128
|
+
const onlineRunners = runners?.filter((r) => r.status === "online").length ?? 0;
|
|
129
|
+
return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
130
|
+
"Jobs running: ",
|
|
131
|
+
runningJobs,
|
|
132
|
+
" | Runners online: ",
|
|
133
|
+
onlineRunners
|
|
134
|
+
] }) });
|
|
135
|
+
}
|
|
136
|
+
var init_StatusBar = __esm({
|
|
137
|
+
"src/tui/StatusBar.tsx"() {
|
|
138
|
+
"use strict";
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// src/tui/RepoListScreen.tsx
|
|
143
|
+
import { useState as useState2 } from "react";
|
|
144
|
+
import { Box as Box2, Text as Text2, useInput } from "ink";
|
|
145
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
146
|
+
function RepoListScreen({ config, onSelect }) {
|
|
147
|
+
const repos = usePoll(() => listRepositories(config), 1e4);
|
|
148
|
+
const [cursor, setCursor] = useState2(0);
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
if (!repos) return;
|
|
151
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
152
|
+
else if (key.downArrow) setCursor((c) => Math.min(repos.length - 1, c + 1));
|
|
153
|
+
else if (key.return) {
|
|
154
|
+
const repo = repos[cursor];
|
|
155
|
+
if (repo) onSelect(repo);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
if (!repos) return /* @__PURE__ */ jsx2(Text2, { children: "Loading repositories\u2026" });
|
|
159
|
+
if (repos.length === 0) return /* @__PURE__ */ jsx2(Text2, { children: "No repositories found." });
|
|
160
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
161
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
|
|
162
|
+
repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
|
|
163
|
+
i === cursor ? "\u25B6 " : " ",
|
|
164
|
+
repo.fullName
|
|
165
|
+
] }, repo.id))
|
|
166
|
+
] });
|
|
167
|
+
}
|
|
168
|
+
var init_RepoListScreen = __esm({
|
|
169
|
+
"src/tui/RepoListScreen.tsx"() {
|
|
170
|
+
"use strict";
|
|
171
|
+
init_usePoll();
|
|
172
|
+
init_tui_api();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// src/tui/RequestListScreen.tsx
|
|
177
|
+
import { useState as useState3 } from "react";
|
|
178
|
+
import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
|
|
179
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
180
|
+
function RequestListScreen({ config, repo, onOpen, onNew, onBack }) {
|
|
181
|
+
const requests = usePoll(() => listRequests(config, repo.id), 5e3);
|
|
182
|
+
const [cursor, setCursor] = useState3(0);
|
|
183
|
+
useInput2((input, key) => {
|
|
184
|
+
if (key.escape) {
|
|
185
|
+
onBack();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!requests) return;
|
|
189
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
190
|
+
else if (key.downArrow) setCursor((c) => Math.min(requests.length - 1, c + 1));
|
|
191
|
+
else if (key.return) {
|
|
192
|
+
const req = requests[cursor];
|
|
193
|
+
if (req) onOpen(req);
|
|
194
|
+
} else if (input === "n") {
|
|
195
|
+
onNew();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (!requests) return /* @__PURE__ */ jsx3(Text3, { children: "Loading requests\u2026" });
|
|
199
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
200
|
+
/* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
|
|
201
|
+
repo.fullName,
|
|
202
|
+
" \u2014 Requests (\u2191\u2193 Enter open, n new, Esc back):"
|
|
203
|
+
] }),
|
|
204
|
+
requests.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No requests yet." }),
|
|
205
|
+
requests.map((req, i) => /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
|
|
206
|
+
i === cursor ? "\u25B6 " : " ",
|
|
207
|
+
"[",
|
|
208
|
+
req.status,
|
|
209
|
+
"] ",
|
|
210
|
+
req.title
|
|
211
|
+
] }, req.id))
|
|
212
|
+
] });
|
|
213
|
+
}
|
|
214
|
+
var init_RequestListScreen = __esm({
|
|
215
|
+
"src/tui/RequestListScreen.tsx"() {
|
|
216
|
+
"use strict";
|
|
217
|
+
init_usePoll();
|
|
218
|
+
init_tui_api();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// src/tui/NewRequestScreen.tsx
|
|
223
|
+
import { useState as useState4 } from "react";
|
|
224
|
+
import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
|
|
225
|
+
import TextInput from "ink-text-input";
|
|
226
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
227
|
+
function NewRequestScreen({ config, repo, onCreated, onBack }) {
|
|
228
|
+
const agents = usePoll(() => listAgents(config), 3e4);
|
|
229
|
+
const [field, setField] = useState4("title");
|
|
230
|
+
const [title, setTitle] = useState4("");
|
|
231
|
+
const [body, setBody] = useState4("");
|
|
232
|
+
const [agentCursor, setAgentCursor] = useState4(0);
|
|
233
|
+
const [branch, setBranch] = useState4(repo.defaultBranch ?? "");
|
|
234
|
+
const [error, setError] = useState4(null);
|
|
235
|
+
const [submitting, setSubmitting] = useState4(false);
|
|
236
|
+
useInput3((_input, key) => {
|
|
237
|
+
if (key.escape) {
|
|
238
|
+
onBack();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (field === "title" && key.tab) {
|
|
242
|
+
setField("body");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (field === "body" && key.tab) {
|
|
246
|
+
setField("agent");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (field === "agent") {
|
|
250
|
+
if (key.upArrow && agents) setAgentCursor((c) => Math.max(0, c - 1));
|
|
251
|
+
if (key.downArrow && agents) setAgentCursor((c) => Math.min((agents.length || 1) - 1, c + 1));
|
|
252
|
+
if (key.tab) setField("branch");
|
|
253
|
+
}
|
|
254
|
+
if (field === "branch" && key.return && !submitting) {
|
|
255
|
+
void submit();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
async function submit() {
|
|
259
|
+
if (!title.trim() || !agents?.[agentCursor]) return;
|
|
260
|
+
setSubmitting(true);
|
|
261
|
+
setError(null);
|
|
262
|
+
try {
|
|
263
|
+
const result = await createRequest(config, {
|
|
264
|
+
title: title.trim(),
|
|
265
|
+
body: body.trim(),
|
|
266
|
+
agentId: agents[agentCursor].id,
|
|
267
|
+
repoId: repo.id,
|
|
268
|
+
checkoutBranch: branch.trim() || void 0
|
|
269
|
+
});
|
|
270
|
+
onCreated(result.requestId);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
273
|
+
setSubmitting(false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
|
|
277
|
+
/* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
|
|
278
|
+
"New Request in ",
|
|
279
|
+
repo.fullName,
|
|
280
|
+
" (Tab to advance, Esc back)"
|
|
281
|
+
] }),
|
|
282
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
283
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Title: " }),
|
|
284
|
+
/* @__PURE__ */ jsx4(TextInput, { value: title, onChange: setTitle, focus: field === "title" })
|
|
285
|
+
] }),
|
|
286
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
287
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Body: " }),
|
|
288
|
+
/* @__PURE__ */ jsx4(TextInput, { value: body, onChange: setBody, focus: field === "body" })
|
|
289
|
+
] }),
|
|
290
|
+
/* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
291
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
292
|
+
"Agent",
|
|
293
|
+
field === "agent" ? " (\u2191\u2193 to pick, Tab next)" : "",
|
|
294
|
+
":"
|
|
295
|
+
] }),
|
|
296
|
+
(agents ?? []).map((a, i) => /* @__PURE__ */ jsxs4(Text4, { color: field === "agent" && i === agentCursor ? "cyan" : void 0, children: [
|
|
297
|
+
field === "agent" && i === agentCursor ? "\u25B6 " : " ",
|
|
298
|
+
a.name
|
|
299
|
+
] }, a.id))
|
|
300
|
+
] }),
|
|
301
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
302
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Checkout branch (optional): " }),
|
|
303
|
+
/* @__PURE__ */ jsx4(TextInput, { value: branch, onChange: setBranch, focus: field === "branch" })
|
|
304
|
+
] }),
|
|
305
|
+
submitting && /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Creating\u2026" }),
|
|
306
|
+
error && /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
|
|
307
|
+
"Error: ",
|
|
308
|
+
error
|
|
309
|
+
] })
|
|
310
|
+
] });
|
|
311
|
+
}
|
|
312
|
+
var init_NewRequestScreen = __esm({
|
|
313
|
+
"src/tui/NewRequestScreen.tsx"() {
|
|
314
|
+
"use strict";
|
|
315
|
+
init_usePoll();
|
|
316
|
+
init_tui_api();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// src/tui/WidgetAnswerScreen.tsx
|
|
321
|
+
import { useState as useState5 } from "react";
|
|
322
|
+
import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
|
|
323
|
+
import TextInput2 from "ink-text-input";
|
|
324
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
325
|
+
function renderOptionLabel(opt, i, cursor, isSingle, checked) {
|
|
326
|
+
const pointer = i === cursor ? "\u25B6 " : " ";
|
|
327
|
+
if (isSingle) {
|
|
328
|
+
return `${pointer}${opt.label}`;
|
|
329
|
+
}
|
|
330
|
+
return `${pointer}[${checked.has(opt.id) ? "x" : " "}] ${opt.label}`;
|
|
331
|
+
}
|
|
332
|
+
function WidgetAnswerScreen({ config, message, onDone }) {
|
|
333
|
+
const [widgetIndex, setWidgetIndex] = useState5(0);
|
|
334
|
+
const [cursor, setCursor] = useState5(0);
|
|
335
|
+
const [checked, setChecked] = useState5(/* @__PURE__ */ new Set());
|
|
336
|
+
const [customText, setCustomText] = useState5("");
|
|
337
|
+
const [showCustom, setShowCustom] = useState5(false);
|
|
338
|
+
const [error, setError] = useState5(null);
|
|
339
|
+
const [submitting, setSubmitting] = useState5(false);
|
|
340
|
+
const widget = message.widgets[widgetIndex];
|
|
341
|
+
function handleSingleInput(input, key, w) {
|
|
342
|
+
const opts2 = [...w.options, { id: "__other__", label: "Other" }];
|
|
343
|
+
if (key.upArrow) {
|
|
344
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
345
|
+
} else if (key.downArrow) {
|
|
346
|
+
setCursor((c) => Math.min(opts2.length - 1, c + 1));
|
|
347
|
+
} else if (key.return) {
|
|
348
|
+
const picked = opts2[cursor];
|
|
349
|
+
if (picked?.id === "__other__") {
|
|
350
|
+
setShowCustom(true);
|
|
351
|
+
} else {
|
|
352
|
+
void advanceOrSubmit(w, picked?.id, void 0, void 0);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function handleMultiInput(input, key, w) {
|
|
357
|
+
const opts2 = [...w.options, { id: "__other__", label: "Other" }];
|
|
358
|
+
if (key.upArrow) {
|
|
359
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
360
|
+
} else if (key.downArrow) {
|
|
361
|
+
setCursor((c) => Math.min(opts2.length - 1, c + 1));
|
|
362
|
+
} else if (input === " ") {
|
|
363
|
+
const picked = opts2[cursor];
|
|
364
|
+
if (!picked) return;
|
|
365
|
+
if (picked.id === "__other__") {
|
|
366
|
+
setShowCustom((s) => !s);
|
|
367
|
+
} else {
|
|
368
|
+
const next = new Set(checked);
|
|
369
|
+
if (next.has(picked.id)) next.delete(picked.id);
|
|
370
|
+
else next.add(picked.id);
|
|
371
|
+
setChecked(next);
|
|
372
|
+
}
|
|
373
|
+
} else if (key.return) {
|
|
374
|
+
void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
useInput4((input, key) => {
|
|
378
|
+
if (!widget) return;
|
|
379
|
+
if (widget.type === "single_select") {
|
|
380
|
+
handleSingleInput(input, key, widget);
|
|
381
|
+
} else {
|
|
382
|
+
handleMultiInput(input, key, widget);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
async function advanceOrSubmit(w, optionId, optionIds, custom) {
|
|
386
|
+
const answers = [
|
|
387
|
+
{
|
|
388
|
+
widgetId: w.id,
|
|
389
|
+
...optionId !== void 0 ? { optionId } : {},
|
|
390
|
+
...optionIds !== void 0 ? { optionIds } : {},
|
|
391
|
+
...custom ? { customText: custom } : {}
|
|
392
|
+
}
|
|
393
|
+
];
|
|
394
|
+
if (widgetIndex < message.widgets.length - 1) {
|
|
395
|
+
setWidgetIndex((i) => i + 1);
|
|
396
|
+
setCursor(0);
|
|
397
|
+
setChecked(/* @__PURE__ */ new Set());
|
|
398
|
+
setCustomText("");
|
|
399
|
+
setShowCustom(false);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
setSubmitting(true);
|
|
403
|
+
setError(null);
|
|
404
|
+
try {
|
|
405
|
+
await answerWidgets(config, message.id, { requestId: message.requestId, answers });
|
|
406
|
+
onDone();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
409
|
+
setSubmitting(false);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!widget) return /* @__PURE__ */ jsx5(Text5, { children: "No widgets to answer." });
|
|
413
|
+
const isSingle = widget.type === "single_select";
|
|
414
|
+
const opts = [...widget.options, { id: "__other__", label: "Other" }];
|
|
415
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
|
|
416
|
+
/* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
|
|
417
|
+
"Widget ",
|
|
418
|
+
widgetIndex + 1,
|
|
419
|
+
"/",
|
|
420
|
+
message.widgets.length,
|
|
421
|
+
": ",
|
|
422
|
+
widget.question
|
|
423
|
+
] }),
|
|
424
|
+
widget.body && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: widget.body }),
|
|
425
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 to navigate, Enter to select" : "\u2191\u2193 to navigate, Space to toggle, Enter to confirm" }),
|
|
426
|
+
opts.map((opt, i) => /* @__PURE__ */ jsx5(Text5, { color: i === cursor ? "cyan" : void 0, children: renderOptionLabel(opt, i, cursor, isSingle, checked) }, opt.id)),
|
|
427
|
+
showCustom && /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
428
|
+
/* @__PURE__ */ jsx5(Text5, { children: "Custom answer: " }),
|
|
429
|
+
/* @__PURE__ */ jsx5(TextInput2, { value: customText, onChange: setCustomText, focus: showCustom })
|
|
430
|
+
] }),
|
|
431
|
+
submitting && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Submitting\u2026" }),
|
|
432
|
+
error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
|
|
433
|
+
"Error: ",
|
|
434
|
+
error
|
|
435
|
+
] })
|
|
436
|
+
] });
|
|
437
|
+
}
|
|
438
|
+
var init_WidgetAnswerScreen = __esm({
|
|
439
|
+
"src/tui/WidgetAnswerScreen.tsx"() {
|
|
440
|
+
"use strict";
|
|
441
|
+
init_tui_api();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// src/tui/ThreadScreen.tsx
|
|
446
|
+
import { useState as useState6 } from "react";
|
|
447
|
+
import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
|
|
448
|
+
import TextInput3 from "ink-text-input";
|
|
449
|
+
import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
450
|
+
function ThreadScreen({ config, repo, requestId, onBack }) {
|
|
451
|
+
const data = usePoll(() => getRequest(config, requestId), 3e3);
|
|
452
|
+
const [cursor, setCursor] = useState6(0);
|
|
453
|
+
const [overlay, setOverlay] = useState6({ kind: "none" });
|
|
454
|
+
const [targetBranch, setTargetBranch] = useState6(repo.defaultBranch ?? "main");
|
|
455
|
+
const [mergeBranch, setMergeBranch] = useState6(repo.defaultBranch ?? "");
|
|
456
|
+
const [acceptStep, setAcceptStep] = useState6("target");
|
|
457
|
+
const [error, setError] = useState6(null);
|
|
458
|
+
const messages = data?.messages ?? [];
|
|
459
|
+
const sessions = data?.sessions ?? [];
|
|
460
|
+
const selectedMsg = messages[cursor];
|
|
461
|
+
const implementableSession = sessions.find((s) => s.status === "planned" || s.status === "ready");
|
|
462
|
+
useInput5((_input, key) => {
|
|
463
|
+
if (overlay.kind !== "none") return;
|
|
464
|
+
if (key.escape) {
|
|
465
|
+
onBack();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (key.upArrow) {
|
|
469
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (key.downArrow) {
|
|
473
|
+
setCursor((c) => Math.min(messages.length - 1, c + 1));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (key.return && selectedMsg) {
|
|
477
|
+
if (selectedMsg.widgets?.length) {
|
|
478
|
+
const widgets = selectedMsg.widgets;
|
|
479
|
+
setOverlay({ kind: "widget", message: { ...selectedMsg, widgets, requestId } });
|
|
480
|
+
} else if (selectedMsg.type === "plan") {
|
|
481
|
+
setTargetBranch(repo.defaultBranch ?? "main");
|
|
482
|
+
setMergeBranch(repo.defaultBranch ?? "");
|
|
483
|
+
setAcceptStep("target");
|
|
484
|
+
setOverlay({ kind: "accept", messageId: selectedMsg.id });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (_input === "i" && implementableSession) {
|
|
488
|
+
void doImplement(implementableSession.id);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
async function doAcceptPlan() {
|
|
492
|
+
if (overlay.kind !== "accept") return;
|
|
493
|
+
try {
|
|
494
|
+
await acceptPlan(
|
|
495
|
+
config,
|
|
496
|
+
overlay.messageId,
|
|
497
|
+
targetBranch.trim() || "main",
|
|
498
|
+
mergeBranch.trim()
|
|
499
|
+
);
|
|
500
|
+
setOverlay({ kind: "accepted" });
|
|
501
|
+
} catch (err) {
|
|
502
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function doImplement(sessionId) {
|
|
506
|
+
try {
|
|
507
|
+
await startImplementing(config, sessionId);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (overlay.kind === "widget") {
|
|
513
|
+
return /* @__PURE__ */ jsx6(
|
|
514
|
+
WidgetAnswerScreen,
|
|
515
|
+
{
|
|
516
|
+
config,
|
|
517
|
+
message: overlay.message,
|
|
518
|
+
onDone: () => setOverlay({ kind: "none" })
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (overlay.kind === "accept") {
|
|
523
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
|
|
524
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Accept Plan" }),
|
|
525
|
+
/* @__PURE__ */ jsx6(Text6, { children: "Enter target branch (base):" }),
|
|
526
|
+
/* @__PURE__ */ jsx6(
|
|
527
|
+
TextInput3,
|
|
528
|
+
{
|
|
529
|
+
value: targetBranch,
|
|
530
|
+
onChange: setTargetBranch,
|
|
531
|
+
onSubmit: () => setAcceptStep("merge"),
|
|
532
|
+
focus: acceptStep === "target"
|
|
533
|
+
}
|
|
534
|
+
),
|
|
535
|
+
acceptStep === "merge" && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
536
|
+
/* @__PURE__ */ jsx6(Text6, { children: "Enter merge branch name:" }),
|
|
537
|
+
/* @__PURE__ */ jsx6(
|
|
538
|
+
TextInput3,
|
|
539
|
+
{
|
|
540
|
+
value: mergeBranch,
|
|
541
|
+
onChange: setMergeBranch,
|
|
542
|
+
onSubmit: () => void doAcceptPlan(),
|
|
543
|
+
focus: true
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
] }),
|
|
547
|
+
error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
|
|
548
|
+
] });
|
|
549
|
+
}
|
|
550
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
551
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Thread (\u2191\u2193 navigate, Enter act on selected, i implement, Esc back)" }),
|
|
552
|
+
!data && /* @__PURE__ */ jsx6(Text6, { children: "Loading\u2026" }),
|
|
553
|
+
overlay.kind === "accepted" && /* @__PURE__ */ jsx6(Text6, { color: "green", children: "Plan accepted! Job queued." }),
|
|
554
|
+
messages.map((msg, i) => /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", marginY: 0, children: /* @__PURE__ */ jsxs6(Text6, { color: i === cursor ? "cyan" : void 0, children: [
|
|
555
|
+
i === cursor ? "\u25B6 " : " ",
|
|
556
|
+
"[",
|
|
557
|
+
msg.type,
|
|
558
|
+
"] ",
|
|
559
|
+
msg.body?.slice(0, 80) ?? "",
|
|
560
|
+
msg.widgets?.length ? ` (${msg.widgets.length} widget${msg.widgets.length > 1 ? "s" : ""})` : ""
|
|
561
|
+
] }) }, msg.id)),
|
|
562
|
+
implementableSession && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
563
|
+
"Press i to start implementing session ",
|
|
564
|
+
implementableSession.id.slice(0, 8),
|
|
565
|
+
"\u2026"
|
|
566
|
+
] }),
|
|
567
|
+
error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
|
|
568
|
+
] });
|
|
569
|
+
}
|
|
570
|
+
var init_ThreadScreen = __esm({
|
|
571
|
+
"src/tui/ThreadScreen.tsx"() {
|
|
572
|
+
"use strict";
|
|
573
|
+
init_usePoll();
|
|
574
|
+
init_tui_api();
|
|
575
|
+
init_WidgetAnswerScreen();
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// src/tui/App.tsx
|
|
580
|
+
var App_exports = {};
|
|
581
|
+
__export(App_exports, {
|
|
582
|
+
App: () => App
|
|
583
|
+
});
|
|
584
|
+
import { useState as useState7 } from "react";
|
|
585
|
+
import { Box as Box7 } from "ink";
|
|
586
|
+
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
587
|
+
function renderScreen(screen, setScreen, config) {
|
|
588
|
+
switch (screen.name) {
|
|
589
|
+
case "repos":
|
|
590
|
+
return /* @__PURE__ */ jsx7(
|
|
591
|
+
RepoListScreen,
|
|
592
|
+
{
|
|
593
|
+
config,
|
|
594
|
+
onSelect: (repo) => setScreen({ name: "requests", repo })
|
|
595
|
+
}
|
|
596
|
+
);
|
|
597
|
+
case "requests":
|
|
598
|
+
return /* @__PURE__ */ jsx7(
|
|
599
|
+
RequestListScreen,
|
|
600
|
+
{
|
|
601
|
+
config,
|
|
602
|
+
repo: screen.repo,
|
|
603
|
+
onOpen: (req) => setScreen({ name: "thread", repo: screen.repo, requestId: req.id }),
|
|
604
|
+
onNew: () => setScreen({ name: "new-request", repo: screen.repo }),
|
|
605
|
+
onBack: () => setScreen({ name: "repos" })
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
case "new-request":
|
|
609
|
+
return /* @__PURE__ */ jsx7(
|
|
610
|
+
NewRequestScreen,
|
|
611
|
+
{
|
|
612
|
+
config,
|
|
613
|
+
repo: screen.repo,
|
|
614
|
+
onCreated: (requestId) => setScreen({ name: "thread", repo: screen.repo, requestId }),
|
|
615
|
+
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
case "thread":
|
|
619
|
+
return /* @__PURE__ */ jsx7(
|
|
620
|
+
ThreadScreen,
|
|
621
|
+
{
|
|
622
|
+
config,
|
|
623
|
+
repo: screen.repo,
|
|
624
|
+
requestId: screen.requestId,
|
|
625
|
+
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
626
|
+
}
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function App({ config }) {
|
|
631
|
+
const [screen, setScreen] = useState7({ name: "repos" });
|
|
632
|
+
const jobs = usePoll(() => listJobs(config), 3e3);
|
|
633
|
+
const runners = usePoll(() => listRunners(config), 3e3);
|
|
634
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
635
|
+
renderScreen(screen, setScreen, config),
|
|
636
|
+
/* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
|
|
637
|
+
] });
|
|
638
|
+
}
|
|
639
|
+
var init_App = __esm({
|
|
640
|
+
"src/tui/App.tsx"() {
|
|
641
|
+
"use strict";
|
|
642
|
+
init_tui_api();
|
|
643
|
+
init_usePoll();
|
|
644
|
+
init_StatusBar();
|
|
645
|
+
init_RepoListScreen();
|
|
646
|
+
init_RequestListScreen();
|
|
647
|
+
init_NewRequestScreen();
|
|
648
|
+
init_ThreadScreen();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
2
651
|
|
|
3
652
|
// src/cli.ts
|
|
4
653
|
import { createInterface } from "node:readline/promises";
|
|
@@ -15,7 +664,9 @@ function readConfig() {
|
|
|
15
664
|
try {
|
|
16
665
|
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
17
666
|
if (!parsed.serverUrl || !parsed.token) return null;
|
|
18
|
-
|
|
667
|
+
const config = { serverUrl: parsed.serverUrl, token: parsed.token };
|
|
668
|
+
if (parsed.apiToken) config.apiToken = parsed.apiToken;
|
|
669
|
+
return config;
|
|
19
670
|
} catch {
|
|
20
671
|
return null;
|
|
21
672
|
}
|
|
@@ -26,38 +677,11 @@ function writeConfig(config) {
|
|
|
26
677
|
}
|
|
27
678
|
|
|
28
679
|
// src/run.ts
|
|
29
|
-
import { existsSync as
|
|
30
|
-
import { join as
|
|
31
|
-
|
|
32
|
-
// src/version.ts
|
|
33
|
-
import { readFileSync as readFileSync2 } from "node:fs";
|
|
34
|
-
import { fileURLToPath } from "node:url";
|
|
35
|
-
function readVersion() {
|
|
36
|
-
try {
|
|
37
|
-
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
38
|
-
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
39
|
-
return pkg.version ?? "unknown";
|
|
40
|
-
} catch {
|
|
41
|
-
return "unknown";
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
var RUNNER_VERSION = readVersion();
|
|
45
|
-
var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
46
|
-
var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
|
|
47
|
-
var RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
|
|
48
|
-
function compareVersions(a, b) {
|
|
49
|
-
const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
50
|
-
const pa = parse(a);
|
|
51
|
-
const pb = parse(b);
|
|
52
|
-
const len = Math.max(pa.length, pb.length);
|
|
53
|
-
for (let i = 0; i < len; i++) {
|
|
54
|
-
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
55
|
-
if (diff !== 0) return diff;
|
|
56
|
-
}
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
680
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
681
|
+
import { join as join6 } from "node:path";
|
|
59
682
|
|
|
60
683
|
// src/api.ts
|
|
684
|
+
init_version();
|
|
61
685
|
var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
|
|
62
686
|
var lastOutdatedWarnAt = 0;
|
|
63
687
|
var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
|
|
@@ -172,6 +796,20 @@ async function fetchRelatedSessions(config, params) {
|
|
|
172
796
|
return [];
|
|
173
797
|
}
|
|
174
798
|
}
|
|
799
|
+
async function mintUploadUrls(config, jobId, keys) {
|
|
800
|
+
const res = await fetch(`${config.serverUrl}/api/runner/previews/mint-upload-urls`, {
|
|
801
|
+
method: "POST",
|
|
802
|
+
headers: {
|
|
803
|
+
authorization: `Bearer ${config.token}`,
|
|
804
|
+
"content-type": "application/json",
|
|
805
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
806
|
+
},
|
|
807
|
+
body: JSON.stringify({ jobId, keys })
|
|
808
|
+
});
|
|
809
|
+
noteServerVersion(res);
|
|
810
|
+
if (!res.ok) throw new Error(`mint-upload-urls failed: ${res.status} ${await safeText(res)}`);
|
|
811
|
+
return res.json();
|
|
812
|
+
}
|
|
175
813
|
async function safeText(res) {
|
|
176
814
|
try {
|
|
177
815
|
return await res.text();
|
|
@@ -320,6 +958,14 @@ var stepSchema = z2.object({
|
|
|
320
958
|
"Per-file pseudo code. Provide an entry for every non-documentation file this step touches. Each entry contains the file path and pseudo code describing the changes to that file."
|
|
321
959
|
)
|
|
322
960
|
});
|
|
961
|
+
var requirementSchema = z2.object({
|
|
962
|
+
requirement: z2.string().min(1).describe(
|
|
963
|
+
"A human-readable statement of what this change must accomplish and why, in plain language a non-technical reader can follow. Distinct from acceptanceCriteria: requirements explain intent/rationale; acceptance criteria are the machine-checkable proof. " + INLINE_CODE_HINT
|
|
964
|
+
),
|
|
965
|
+
acceptanceCriteria: z2.array(z2.string().min(1)).min(1).describe(
|
|
966
|
+
"Concrete, deterministically-checkable conditions that prove this requirement is satisfied. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. " + INLINE_CODE_HINT
|
|
967
|
+
)
|
|
968
|
+
});
|
|
323
969
|
var planInputSchema = {
|
|
324
970
|
title: z2.string().min(1).max(120).describe(
|
|
325
971
|
"A concise, descriptive name for THIS plan. Must be distinct from the request title and from any sibling plans on the same request. Keep it under 120 characters."
|
|
@@ -333,13 +979,10 @@ var planInputSchema = {
|
|
|
333
979
|
"Why the user is making this request \u2014 the underlying motivation or problem the change addresses. Fill this especially when the request content/context does NOT already state the why (ask the user in the Clarify phase); omit when there is no additional motivation to record. Useful for future understanding of the system. " + INLINE_CODE_HINT
|
|
334
980
|
),
|
|
335
981
|
assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
|
|
336
|
-
requirements: z2.array(
|
|
337
|
-
"Required, human-readable statements of what this change must accomplish and why,
|
|
982
|
+
requirements: z2.array(requirementSchema).min(1).describe(
|
|
983
|
+
"Required, human-readable statements of what this change must accomplish and why, each carrying its own acceptanceCriteria. At least 1 requirement required; at least 2 acceptance criteria total across all requirements. " + INLINE_CODE_HINT
|
|
338
984
|
),
|
|
339
985
|
steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
|
|
340
|
-
acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
|
|
341
|
-
"Concrete, deterministically-checkable conditions that together define done. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. The set must collectively cover every step's change. At least 2 required. " + INLINE_CODE_HINT
|
|
342
|
-
),
|
|
343
986
|
risks: z2.array(z2.string()).describe("Anything that could change the approach."),
|
|
344
987
|
outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
|
|
345
988
|
};
|
|
@@ -354,7 +997,21 @@ function requireRootCauseForFix(schema) {
|
|
|
354
997
|
}
|
|
355
998
|
});
|
|
356
999
|
}
|
|
357
|
-
|
|
1000
|
+
function requireAtLeastTwoCriteria(schema) {
|
|
1001
|
+
return schema.superRefine((plan, ctx) => {
|
|
1002
|
+
const total = plan.requirements.reduce((sum, r) => sum + r.acceptanceCriteria.length, 0);
|
|
1003
|
+
if (total < 2) {
|
|
1004
|
+
ctx.addIssue({
|
|
1005
|
+
code: z2.ZodIssueCode.custom,
|
|
1006
|
+
path: ["requirements"],
|
|
1007
|
+
message: "At least 2 acceptance criteria total across all requirements are required."
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
var planSchema = requireAtLeastTwoCriteria(
|
|
1013
|
+
requireRootCauseForFix(z2.object(planInputSchema))
|
|
1014
|
+
);
|
|
358
1015
|
function renderPlan(plan) {
|
|
359
1016
|
const lines2 = [];
|
|
360
1017
|
lines2.push(`# ${plan.title}`);
|
|
@@ -381,8 +1038,8 @@ function renderPlan(plan) {
|
|
|
381
1038
|
}
|
|
382
1039
|
lines2.push("");
|
|
383
1040
|
lines2.push("## Requirements");
|
|
384
|
-
for (const
|
|
385
|
-
lines2.push(`- ${requirement}`);
|
|
1041
|
+
for (const req of plan.requirements) {
|
|
1042
|
+
lines2.push(`- ${req.requirement}`);
|
|
386
1043
|
}
|
|
387
1044
|
lines2.push("");
|
|
388
1045
|
lines2.push("## Steps");
|
|
@@ -407,8 +1064,11 @@ function renderPlan(plan) {
|
|
|
407
1064
|
}
|
|
408
1065
|
lines2.push("");
|
|
409
1066
|
lines2.push("## Acceptance criteria");
|
|
410
|
-
for (const
|
|
411
|
-
lines2.push(
|
|
1067
|
+
for (const req of plan.requirements) {
|
|
1068
|
+
lines2.push(`### ${req.requirement}`);
|
|
1069
|
+
for (const criterion of req.acceptanceCriteria) {
|
|
1070
|
+
lines2.push(`- [ ] ${criterion}`);
|
|
1071
|
+
}
|
|
412
1072
|
}
|
|
413
1073
|
if (plan.risks.length > 0) {
|
|
414
1074
|
lines2.push("");
|
|
@@ -429,7 +1089,7 @@ function renderPlan(plan) {
|
|
|
429
1089
|
return lines2.join("\n");
|
|
430
1090
|
}
|
|
431
1091
|
var submitPlanInputSchema = {
|
|
432
|
-
plans: z2.array(requireRootCauseForFix(z2.object(planInputSchema))).min(1).refine(
|
|
1092
|
+
plans: z2.array(requireAtLeastTwoCriteria(requireRootCauseForFix(z2.object(planInputSchema)))).min(1).refine(
|
|
433
1093
|
(arr) => {
|
|
434
1094
|
const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
|
|
435
1095
|
return new Set(titles).size === titles.length;
|
|
@@ -442,7 +1102,7 @@ function createPlanTooling() {
|
|
|
442
1102
|
let renderedPlans = null;
|
|
443
1103
|
const submitPlan = tool2(
|
|
444
1104
|
SUBMIT_PLAN,
|
|
445
|
-
`Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once.
|
|
1105
|
+
`Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. requirements is required in each plan: an array of objects, each with a requirement (plain-language intent) and an acceptanceCriteria array (machine-checkable proof for that requirement). At least 1 requirement required; at least 2 acceptance criteria total across all requirements. Each requirement's acceptanceCriteria must be non-empty (at least 1 item). The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. When a plan's scope is "fix", rootCause is required: a non-empty explanation of the underlying cause of the bug (not just the symptom). motivation is optional: the user's stated or asked-for reason for the request. `,
|
|
446
1106
|
submitPlanInputSchema,
|
|
447
1107
|
async (args) => {
|
|
448
1108
|
const parsed = submitPlanSchema.parse(args);
|
|
@@ -510,6 +1170,9 @@ var acVerdictSchema = z3.object({
|
|
|
510
1170
|
rationale: z3.string().min(1).describe("One or two sentences on why the verdict holds. " + INLINE_CODE_HINT),
|
|
511
1171
|
evidence: z3.array(evidenceSchema).describe(
|
|
512
1172
|
"Diff hunks proving the verdict, copied verbatim from git --no-pager diff. Across ALL criteria the evidence must collectively cover every hunk in the diff \u2014 each changed hunk appears under at least one criterion. Cite the relevant hunk(s) for a met criterion; may be empty for not_met / unclear."
|
|
1173
|
+
),
|
|
1174
|
+
requirement: z3.string().min(1).optional().describe(
|
|
1175
|
+
"The verbatim requirement text from the plan that this criterion appears under. Used for grouping criteria by requirement in the rendered report. Optional \u2014 omit for resolve runs (no plan) and legacy plans without grouping."
|
|
513
1176
|
)
|
|
514
1177
|
});
|
|
515
1178
|
var reportInputSchema = {
|
|
@@ -534,24 +1197,47 @@ var reportInputSchema = {
|
|
|
534
1197
|
)
|
|
535
1198
|
};
|
|
536
1199
|
var reportSchema = z3.object(reportInputSchema);
|
|
1200
|
+
function groupByRequirement(criteria) {
|
|
1201
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1202
|
+
for (const ac of criteria) {
|
|
1203
|
+
const key = ac.requirement ?? null;
|
|
1204
|
+
const existing = groups.get(key);
|
|
1205
|
+
if (existing) {
|
|
1206
|
+
existing.push(ac);
|
|
1207
|
+
} else {
|
|
1208
|
+
groups.set(key, [ac]);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return groups;
|
|
1212
|
+
}
|
|
1213
|
+
function renderAcVerdict(lines2, ac) {
|
|
1214
|
+
lines2.push("");
|
|
1215
|
+
lines2.push(`#### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
|
|
1216
|
+
lines2.push("");
|
|
1217
|
+
lines2.push(ac.rationale.trim());
|
|
1218
|
+
for (const ev of ac.evidence) {
|
|
1219
|
+
lines2.push("");
|
|
1220
|
+
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
1221
|
+
lines2.push("");
|
|
1222
|
+
lines2.push("```diff");
|
|
1223
|
+
lines2.push(ev.hunk.replace(/\n+$/, ""));
|
|
1224
|
+
lines2.push("```");
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
537
1227
|
function renderReport(report) {
|
|
538
1228
|
const lines2 = [];
|
|
539
1229
|
lines2.push(report.summary.trim());
|
|
540
1230
|
lines2.push("", "## Files changed", "", report.filesChanged.trim());
|
|
541
1231
|
if (report.acceptanceCriteria.length > 0) {
|
|
542
1232
|
lines2.push("", "## Acceptance criteria");
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
lines2.push("");
|
|
547
|
-
lines2.push(ac.rationale.trim());
|
|
548
|
-
for (const ev of ac.evidence) {
|
|
549
|
-
lines2.push("");
|
|
550
|
-
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
1233
|
+
const groups = groupByRequirement(report.acceptanceCriteria);
|
|
1234
|
+
for (const [req, acs] of groups) {
|
|
1235
|
+
if (req !== null) {
|
|
551
1236
|
lines2.push("");
|
|
552
|
-
lines2.push(
|
|
553
|
-
|
|
554
|
-
|
|
1237
|
+
lines2.push(`### ${req}`);
|
|
1238
|
+
}
|
|
1239
|
+
for (const ac of acs) {
|
|
1240
|
+
renderAcVerdict(lines2, ac);
|
|
555
1241
|
}
|
|
556
1242
|
}
|
|
557
1243
|
}
|
|
@@ -575,7 +1261,7 @@ function createReportTooling() {
|
|
|
575
1261
|
let submittedReport = null;
|
|
576
1262
|
const submitReport = tool3(
|
|
577
1263
|
SUBMIT_REPORT,
|
|
578
|
-
"Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion, each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. `cicd` (optional) holds Verify-phase check results (one entry per command with `command`, `status` `passed`/`failed`, and `output` on failure); omit when no verification setup exists. Do NOT include a PR link \u2014 the runner appends it.",
|
|
1264
|
+
"Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion (same count and order), each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. Set `requirement` on each entry to the verbatim text of the `### <requirement>` heading the criterion appeared under in the plan \u2014 this groups criteria by requirement in the rendered report. Omit `requirement` for resolve runs (no plan) or when the plan has no requirement headings. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. `cicd` (optional) holds Verify-phase check results (one entry per command with `command`, `status` `passed`/`failed`, and `output` on failure); omit when no verification setup exists. Do NOT include a PR link \u2014 the runner appends it.",
|
|
579
1265
|
reportInputSchema,
|
|
580
1266
|
async (args) => {
|
|
581
1267
|
submittedReport = reportSchema.parse(args);
|
|
@@ -1103,6 +1789,36 @@ function buildCodexPrompt(ctx) {
|
|
|
1103
1789
|
"\n"
|
|
1104
1790
|
);
|
|
1105
1791
|
}
|
|
1792
|
+
function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
|
|
1793
|
+
const lines2 = [
|
|
1794
|
+
`You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
|
|
1795
|
+
`An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase page that imports the changed components and fills them with realistic fake data.`,
|
|
1796
|
+
`Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
|
|
1797
|
+
`These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
|
|
1798
|
+
"",
|
|
1799
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
1800
|
+
"",
|
|
1801
|
+
loadRule("coding-guideline"),
|
|
1802
|
+
"",
|
|
1803
|
+
`# Request: ${ctx.request?.title ?? ""}`
|
|
1804
|
+
];
|
|
1805
|
+
if (ctx.request?.body) {
|
|
1806
|
+
lines2.push("", ctx.request.body);
|
|
1807
|
+
}
|
|
1808
|
+
if (committedFileNames.trim()) {
|
|
1809
|
+
lines2.push(
|
|
1810
|
+
"",
|
|
1811
|
+
"UI files changed by this implementation (showcase these components):",
|
|
1812
|
+
"",
|
|
1813
|
+
committedFileNames.trim()
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
lines2.push(
|
|
1817
|
+
"",
|
|
1818
|
+
"When done, reply with one short sentence confirming what showcase file you created."
|
|
1819
|
+
);
|
|
1820
|
+
return lines2.join("\n");
|
|
1821
|
+
}
|
|
1106
1822
|
function buildInitPrompt(ctx) {
|
|
1107
1823
|
return [
|
|
1108
1824
|
`You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
@@ -1278,6 +1994,13 @@ async function gitDiffStat(dir) {
|
|
|
1278
1994
|
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
|
|
1279
1995
|
return stdout2;
|
|
1280
1996
|
}
|
|
1997
|
+
async function captureFullDiff(ctx, dir) {
|
|
1998
|
+
const { mergeBranch } = ctx.repo;
|
|
1999
|
+
if (!mergeBranch) return "";
|
|
2000
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
2001
|
+
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "FETCH_HEAD...HEAD"]);
|
|
2002
|
+
return stdout2;
|
|
2003
|
+
}
|
|
1281
2004
|
var PreCommitError = class extends Error {
|
|
1282
2005
|
constructor(log) {
|
|
1283
2006
|
super("pre-commit checks failed");
|
|
@@ -1397,6 +2120,18 @@ async function openPullRequest(ctx) {
|
|
|
1397
2120
|
}
|
|
1398
2121
|
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
1399
2122
|
}
|
|
2123
|
+
async function gitCommittedFiles(dir) {
|
|
2124
|
+
const { stdout: stdout2 } = await git([
|
|
2125
|
+
"-C",
|
|
2126
|
+
dir,
|
|
2127
|
+
"--no-pager",
|
|
2128
|
+
"show",
|
|
2129
|
+
"--name-only",
|
|
2130
|
+
"--format=",
|
|
2131
|
+
"HEAD"
|
|
2132
|
+
]);
|
|
2133
|
+
return stdout2;
|
|
2134
|
+
}
|
|
1400
2135
|
async function cleanup(dir) {
|
|
1401
2136
|
await rm(dir, { recursive: true, force: true });
|
|
1402
2137
|
}
|
|
@@ -1456,6 +2191,267 @@ async function prNumbersForCommit(ctx, sha) {
|
|
|
1456
2191
|
}
|
|
1457
2192
|
}
|
|
1458
2193
|
|
|
2194
|
+
// src/preview.ts
|
|
2195
|
+
import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
|
|
2196
|
+
import { createServer } from "node:net";
|
|
2197
|
+
import { join as join5, relative } from "node:path";
|
|
2198
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2199
|
+
var PREVIEW_MAX_TURNS = 60;
|
|
2200
|
+
var CAPTURE_TIMEOUT_MS = 3e4;
|
|
2201
|
+
var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
|
|
2202
|
+
var VIEWPORTS = [
|
|
2203
|
+
{ name: "mobile", width: 375, height: 812 },
|
|
2204
|
+
{ name: "tablet", width: 768, height: 1024 },
|
|
2205
|
+
{ name: "desktop", width: 1280, height: 800 }
|
|
2206
|
+
];
|
|
2207
|
+
var UI_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2208
|
+
".tsx",
|
|
2209
|
+
".jsx",
|
|
2210
|
+
".vue",
|
|
2211
|
+
".svelte",
|
|
2212
|
+
".astro",
|
|
2213
|
+
".css",
|
|
2214
|
+
".scss",
|
|
2215
|
+
".sass",
|
|
2216
|
+
".less"
|
|
2217
|
+
]);
|
|
2218
|
+
async function buildPreview(ctx, dir, committedFileNames, config, abort) {
|
|
2219
|
+
if (!changedFilesTouchUi(committedFileNames)) {
|
|
2220
|
+
return { status: "skipped", reason: "No UI files were changed in this run." };
|
|
2221
|
+
}
|
|
2222
|
+
try {
|
|
2223
|
+
const { previewId, entrypoint } = await runPreviewPass(
|
|
2224
|
+
ctx,
|
|
2225
|
+
dir,
|
|
2226
|
+
committedFileNames,
|
|
2227
|
+
config,
|
|
2228
|
+
abort
|
|
2229
|
+
);
|
|
2230
|
+
return { status: "ready", previewId, entrypoint };
|
|
2231
|
+
} catch (err) {
|
|
2232
|
+
return { status: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
function changedFilesTouchUi(stat) {
|
|
2236
|
+
for (const line of stat.split("\n")) {
|
|
2237
|
+
const trimmed = line.trim();
|
|
2238
|
+
if (!trimmed) continue;
|
|
2239
|
+
const dot = trimmed.lastIndexOf(".");
|
|
2240
|
+
if (dot === -1) continue;
|
|
2241
|
+
const ext = trimmed.slice(dot);
|
|
2242
|
+
if (UI_EXTENSIONS.has(ext)) return true;
|
|
2243
|
+
}
|
|
2244
|
+
return false;
|
|
2245
|
+
}
|
|
2246
|
+
function detectFramework(dir) {
|
|
2247
|
+
const pm = detectPackageManager(dir);
|
|
2248
|
+
const pkgPath = join5(dir, "package.json");
|
|
2249
|
+
if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
|
|
2250
|
+
let pkg;
|
|
2251
|
+
try {
|
|
2252
|
+
pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
2253
|
+
} catch {
|
|
2254
|
+
return { pm, family: null, devCmd: null };
|
|
2255
|
+
}
|
|
2256
|
+
const deps = {
|
|
2257
|
+
...pkg.dependencies,
|
|
2258
|
+
...pkg.devDependencies
|
|
2259
|
+
};
|
|
2260
|
+
const family = pickFamily(deps);
|
|
2261
|
+
const port = 0;
|
|
2262
|
+
const devCmd = family ? pickDevCmd(family, port) : null;
|
|
2263
|
+
return { pm, family, devCmd };
|
|
2264
|
+
}
|
|
2265
|
+
async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
|
|
2266
|
+
const tmpRoute = mkTempRouteDir(dir);
|
|
2267
|
+
try {
|
|
2268
|
+
gitExclude(dir, tmpRoute);
|
|
2269
|
+
await runClaudeCode({
|
|
2270
|
+
cwd: dir,
|
|
2271
|
+
prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
|
|
2272
|
+
permissionMode: ctx.permissionMode,
|
|
2273
|
+
abortController: abort,
|
|
2274
|
+
maxTurns: PREVIEW_MAX_TURNS
|
|
2275
|
+
});
|
|
2276
|
+
const showcasePath = readShowcasePath(tmpRoute);
|
|
2277
|
+
if (!showcasePath) {
|
|
2278
|
+
throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
|
|
2279
|
+
}
|
|
2280
|
+
const framework = detectFramework(dir);
|
|
2281
|
+
if (!framework.family) {
|
|
2282
|
+
throw new Error("unsupported framework \u2014 skipping preview");
|
|
2283
|
+
}
|
|
2284
|
+
const port = await findFreePort();
|
|
2285
|
+
const devServer = await startDevServer(dir, framework, port);
|
|
2286
|
+
try {
|
|
2287
|
+
const url = `http://localhost:${port}${showcasePath}`;
|
|
2288
|
+
const shots = await captureWithChromium(url, VIEWPORTS);
|
|
2289
|
+
const { previewId, urls } = await mintUploadUrls(config, ctx.jobId, shots.keys);
|
|
2290
|
+
await uploadAll(urls, shots.keys, shots.bytes);
|
|
2291
|
+
return { previewId, entrypoint: shots.entrypointKey };
|
|
2292
|
+
} finally {
|
|
2293
|
+
await devServer.stop();
|
|
2294
|
+
}
|
|
2295
|
+
} finally {
|
|
2296
|
+
rmSync(tmpRoute, { recursive: true, force: true });
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
function pickFamily(deps) {
|
|
2300
|
+
if (deps["next"]) return "next";
|
|
2301
|
+
if (deps["@sveltejs/kit"]) return "svelte-kit";
|
|
2302
|
+
if (deps["nuxt"]) return "nuxt";
|
|
2303
|
+
if (deps["astro"]) return "astro";
|
|
2304
|
+
if (deps["react-scripts"]) return "cra";
|
|
2305
|
+
if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
|
|
2306
|
+
if (deps["vite"]) {
|
|
2307
|
+
if (deps["vue"]) return "vite-vue";
|
|
2308
|
+
if (deps["svelte"]) return "vite-svelte";
|
|
2309
|
+
if (deps["react"]) return "vite-react";
|
|
2310
|
+
return "vite-react";
|
|
2311
|
+
}
|
|
2312
|
+
return null;
|
|
2313
|
+
}
|
|
2314
|
+
function pickDevCmd(family, port) {
|
|
2315
|
+
switch (family) {
|
|
2316
|
+
case "next":
|
|
2317
|
+
return ["next", "dev", "-p", String(port)];
|
|
2318
|
+
case "vite-react":
|
|
2319
|
+
case "vite-vue":
|
|
2320
|
+
case "vite-svelte":
|
|
2321
|
+
return ["vite", "--port", String(port)];
|
|
2322
|
+
case "nuxt":
|
|
2323
|
+
return ["nuxt", "dev", "--port", String(port)];
|
|
2324
|
+
case "svelte-kit":
|
|
2325
|
+
return ["vite", "--port", String(port)];
|
|
2326
|
+
case "astro":
|
|
2327
|
+
return ["astro", "dev", "--port", String(port)];
|
|
2328
|
+
case "cra":
|
|
2329
|
+
return ["react-scripts", "start"];
|
|
2330
|
+
case "remix":
|
|
2331
|
+
return ["remix", "dev", "--port", String(port)];
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
function mkTempRouteDir(dir) {
|
|
2335
|
+
const suffix = Math.random().toString(36).slice(2, 10);
|
|
2336
|
+
const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
|
|
2337
|
+
mkdirSync2(tmpRoute, { recursive: true });
|
|
2338
|
+
return tmpRoute;
|
|
2339
|
+
}
|
|
2340
|
+
function gitExclude(dir, tmpRoute) {
|
|
2341
|
+
const excludePath = join5(dir, ".git", "info", "exclude");
|
|
2342
|
+
const rel = relative(dir, tmpRoute);
|
|
2343
|
+
appendFileSync(excludePath, `
|
|
2344
|
+
${rel}
|
|
2345
|
+
`);
|
|
2346
|
+
}
|
|
2347
|
+
function readShowcasePath(tmpRoute) {
|
|
2348
|
+
const sentinelPath = join5(tmpRoute, ".showcase-path");
|
|
2349
|
+
if (!existsSync4(sentinelPath)) return null;
|
|
2350
|
+
const content = readFileSync5(sentinelPath, "utf8").trim();
|
|
2351
|
+
return content.length > 0 ? content : null;
|
|
2352
|
+
}
|
|
2353
|
+
function findFreePort() {
|
|
2354
|
+
return new Promise((resolve, reject) => {
|
|
2355
|
+
const server = createServer();
|
|
2356
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2357
|
+
const addr = server.address();
|
|
2358
|
+
if (!addr || typeof addr === "string") {
|
|
2359
|
+
server.close();
|
|
2360
|
+
reject(new Error("Could not determine free port"));
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
const port = addr.port;
|
|
2364
|
+
server.close((err) => {
|
|
2365
|
+
if (err) reject(err);
|
|
2366
|
+
else resolve(port);
|
|
2367
|
+
});
|
|
2368
|
+
});
|
|
2369
|
+
server.on("error", reject);
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
function startDevServer(dir, framework, port) {
|
|
2373
|
+
return new Promise((resolve, reject) => {
|
|
2374
|
+
const pm = framework.pm ?? "npm";
|
|
2375
|
+
const devCmd = pickDevCmd(framework.family, port);
|
|
2376
|
+
const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
|
|
2377
|
+
const cmd = pm === "npm" ? "npx" : pm;
|
|
2378
|
+
const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
|
|
2379
|
+
const proc = spawn2(cmd, args, {
|
|
2380
|
+
cwd: dir,
|
|
2381
|
+
env: { ...process.env, PORT: String(port) },
|
|
2382
|
+
stdio: "ignore"
|
|
2383
|
+
});
|
|
2384
|
+
proc.on("error", reject);
|
|
2385
|
+
const baseUrl = `http://localhost:${port}`;
|
|
2386
|
+
const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
|
|
2387
|
+
const poll = () => {
|
|
2388
|
+
fetch(baseUrl).then((res) => {
|
|
2389
|
+
if (res.ok || res.status < 500) {
|
|
2390
|
+
resolve({
|
|
2391
|
+
stop: () => new Promise((res2) => {
|
|
2392
|
+
proc.kill();
|
|
2393
|
+
proc.on("close", () => res2());
|
|
2394
|
+
setTimeout(res2, 500);
|
|
2395
|
+
})
|
|
2396
|
+
});
|
|
2397
|
+
} else {
|
|
2398
|
+
scheduleNext();
|
|
2399
|
+
}
|
|
2400
|
+
}).catch(() => scheduleNext());
|
|
2401
|
+
};
|
|
2402
|
+
const scheduleNext = () => {
|
|
2403
|
+
if (Date.now() > deadline) {
|
|
2404
|
+
proc.kill();
|
|
2405
|
+
reject(
|
|
2406
|
+
new Error(`Dev server did not become ready within ${DEV_SERVER_READY_TIMEOUT_MS}ms`)
|
|
2407
|
+
);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
setTimeout(poll, 1e3);
|
|
2411
|
+
};
|
|
2412
|
+
setTimeout(poll, 2e3);
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
async function captureWithChromium(url, viewports) {
|
|
2416
|
+
const { chromium } = await import("playwright");
|
|
2417
|
+
const browser = await chromium.launch({ headless: true });
|
|
2418
|
+
const keys = [];
|
|
2419
|
+
const bytes = [];
|
|
2420
|
+
try {
|
|
2421
|
+
for (const vp of viewports) {
|
|
2422
|
+
const context = await browser.newContext({
|
|
2423
|
+
viewport: { width: vp.width, height: vp.height }
|
|
2424
|
+
});
|
|
2425
|
+
const page = await context.newPage();
|
|
2426
|
+
await page.goto(url, { timeout: CAPTURE_TIMEOUT_MS, waitUntil: "networkidle" });
|
|
2427
|
+
const screenshot = await page.screenshot({ fullPage: true, type: "png" });
|
|
2428
|
+
keys.push(`${vp.name}.png`);
|
|
2429
|
+
bytes.push(Buffer.from(screenshot));
|
|
2430
|
+
await context.close();
|
|
2431
|
+
}
|
|
2432
|
+
} finally {
|
|
2433
|
+
await browser.close();
|
|
2434
|
+
}
|
|
2435
|
+
return {
|
|
2436
|
+
keys,
|
|
2437
|
+
bytes,
|
|
2438
|
+
entrypointKey: keys[keys.length - 1] ?? "desktop.png"
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
async function uploadAll(urls, keys, bytes) {
|
|
2442
|
+
await Promise.all(
|
|
2443
|
+
keys.map((key, i) => {
|
|
2444
|
+
const url = urls[key];
|
|
2445
|
+
if (!url) return Promise.resolve();
|
|
2446
|
+
return fetch(url, {
|
|
2447
|
+
method: "PUT",
|
|
2448
|
+
headers: { "content-type": "image/png" },
|
|
2449
|
+
body: bytes[i]
|
|
2450
|
+
});
|
|
2451
|
+
})
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
1459
2455
|
// src/run.ts
|
|
1460
2456
|
var IDLE_MS = 5e3;
|
|
1461
2457
|
var CANCEL_POLL_MS = 2500;
|
|
@@ -1650,7 +2646,7 @@ async function processChatJob(ctx, dir, config, abort) {
|
|
|
1650
2646
|
console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1651
2647
|
return { text: reply, widgets: result.widgets };
|
|
1652
2648
|
}
|
|
1653
|
-
const wikiExists =
|
|
2649
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1654
2650
|
let documented = false;
|
|
1655
2651
|
if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
|
|
1656
2652
|
try {
|
|
@@ -1739,7 +2735,7 @@ ${reply}`;
|
|
|
1739
2735
|
|
|
1740
2736
|
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
1741
2737
|
}
|
|
1742
|
-
const wikiExists =
|
|
2738
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1743
2739
|
let documented = false;
|
|
1744
2740
|
if (wikiExists && await hasChanges(dir)) {
|
|
1745
2741
|
try {
|
|
@@ -1763,12 +2759,33 @@ ${reply}`;
|
|
|
1763
2759
|
rebase: !resumed
|
|
1764
2760
|
});
|
|
1765
2761
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1766
|
-
|
|
2762
|
+
let fullDiff;
|
|
2763
|
+
if (report && outcome.kind !== "none") {
|
|
2764
|
+
try {
|
|
2765
|
+
fullDiff = (await captureFullDiff(ctx, dir)).trim() || void 0;
|
|
2766
|
+
} catch (err) {
|
|
2767
|
+
console.warn(` full-diff capture skipped: ${errorMessage2(err)}`);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
const finalReport = report ? {
|
|
2771
|
+
...report,
|
|
2772
|
+
...conflictResolution ? { conflictResolution } : {},
|
|
2773
|
+
...fullDiff ? { fullDiff } : {}
|
|
2774
|
+
} : report;
|
|
2775
|
+
let preview;
|
|
2776
|
+
if (outcome.kind !== "none") {
|
|
2777
|
+
console.log(` \u2026checking UI preview for implement ${ctx.jobId}`);
|
|
2778
|
+
const committedFileNames = await gitCommittedFiles(dir);
|
|
2779
|
+
preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
|
|
2780
|
+
if (preview.status !== "ready")
|
|
2781
|
+
console.log(` UI preview ${preview.status}: ${preview.reason}`);
|
|
2782
|
+
}
|
|
1767
2783
|
return {
|
|
1768
2784
|
text: reply,
|
|
1769
2785
|
widgets: [],
|
|
1770
2786
|
...finalReport ? { report: finalReport } : {},
|
|
1771
|
-
...outcome.kind === "pr" ? { pr: outcome.pr } : {}
|
|
2787
|
+
...outcome.kind === "pr" ? { pr: outcome.pr } : {},
|
|
2788
|
+
...preview ? { preview } : {}
|
|
1772
2789
|
};
|
|
1773
2790
|
}
|
|
1774
2791
|
async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
@@ -1795,7 +2812,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1795
2812
|
console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1796
2813
|
return { text: reply, widgets: result.widgets };
|
|
1797
2814
|
}
|
|
1798
|
-
const wikiExists =
|
|
2815
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1799
2816
|
let documented = false;
|
|
1800
2817
|
if (wikiExists && await hasChanges(dir)) {
|
|
1801
2818
|
try {
|
|
@@ -1822,12 +2839,21 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1822
2839
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1823
2840
|
}
|
|
1824
2841
|
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
2842
|
+
let preview;
|
|
2843
|
+
if (outcome.kind !== "none") {
|
|
2844
|
+
console.log(` \u2026checking UI preview for revise ${ctx.jobId}`);
|
|
2845
|
+
const committedFileNames = await gitCommittedFiles(dir);
|
|
2846
|
+
preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
|
|
2847
|
+
if (preview.status !== "ready")
|
|
2848
|
+
console.log(` UI preview ${preview.status}: ${preview.reason}`);
|
|
2849
|
+
}
|
|
1825
2850
|
return {
|
|
1826
2851
|
text: reply,
|
|
1827
2852
|
widgets: [],
|
|
1828
2853
|
...finalReport ? { report: finalReport } : {},
|
|
1829
2854
|
...outcome.kind === "pr" ? { pr: outcome.pr } : {},
|
|
1830
|
-
...result.plans?.length ? { plans: result.plans } : {}
|
|
2855
|
+
...result.plans?.length ? { plans: result.plans } : {},
|
|
2856
|
+
...preview ? { preview } : {}
|
|
1831
2857
|
};
|
|
1832
2858
|
}
|
|
1833
2859
|
async function processResolveJob(ctx, dir, config, abort) {
|
|
@@ -1939,7 +2965,7 @@ async function pollLoop(config) {
|
|
|
1939
2965
|
scheduleCancelPoll();
|
|
1940
2966
|
try {
|
|
1941
2967
|
resetUsage();
|
|
1942
|
-
const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
|
|
2968
|
+
const { text, widgets, pr, plans, report, preview } = await processJob(ctx, config, abort);
|
|
1943
2969
|
const usage = getUsage();
|
|
1944
2970
|
await reportJob(config, ctx.jobId, {
|
|
1945
2971
|
status: "done",
|
|
@@ -1948,6 +2974,7 @@ async function pollLoop(config) {
|
|
|
1948
2974
|
pr,
|
|
1949
2975
|
...plans?.length ? { plans } : {},
|
|
1950
2976
|
...report ? { report } : {},
|
|
2977
|
+
...preview ? { preview } : {},
|
|
1951
2978
|
...usage.totalTokens > 0 ? { usage } : {}
|
|
1952
2979
|
});
|
|
1953
2980
|
console.log(`\u2713 Job ${ctx.jobId} done`);
|
|
@@ -2027,6 +3054,8 @@ function formatJobError(ctx, err) {
|
|
|
2027
3054
|
}
|
|
2028
3055
|
|
|
2029
3056
|
// src/cli.ts
|
|
3057
|
+
init_version();
|
|
3058
|
+
import React7 from "react";
|
|
2030
3059
|
var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
|
|
2031
3060
|
async function login(args) {
|
|
2032
3061
|
const flags = parseFlags(args);
|
|
@@ -2061,6 +3090,21 @@ async function start() {
|
|
|
2061
3090
|
}
|
|
2062
3091
|
await pollLoop(config);
|
|
2063
3092
|
}
|
|
3093
|
+
async function runTui(args) {
|
|
3094
|
+
const flags = parseFlags(args);
|
|
3095
|
+
let config = readConfig() ?? { serverUrl: flags.server ?? DEFAULT_SERVER, token: "" };
|
|
3096
|
+
if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
|
|
3097
|
+
if (flags.token) config.apiToken = flags.token;
|
|
3098
|
+
if (!config.apiToken) {
|
|
3099
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
3100
|
+
config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
|
|
3101
|
+
rl.close();
|
|
3102
|
+
}
|
|
3103
|
+
writeConfig(config);
|
|
3104
|
+
const { render } = await import("ink");
|
|
3105
|
+
const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
|
|
3106
|
+
render(React7.createElement(App2, { config }));
|
|
3107
|
+
}
|
|
2064
3108
|
function parseFlags(args) {
|
|
2065
3109
|
const out = {};
|
|
2066
3110
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2078,11 +3122,14 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
2078
3122
|
void login(rest);
|
|
2079
3123
|
} else if (command === "start") {
|
|
2080
3124
|
void start();
|
|
3125
|
+
} else if (command === "tui") {
|
|
3126
|
+
void runTui(rest);
|
|
2081
3127
|
} else {
|
|
2082
3128
|
console.log(`FlumeCode runner v${RUNNER_VERSION}`);
|
|
2083
3129
|
console.log("Usage:");
|
|
2084
3130
|
console.log(" flumecode login # save server URL + token");
|
|
2085
3131
|
console.log(" flumecode start # poll for and run jobs");
|
|
3132
|
+
console.log(" flumecode tui # open the interactive TUI");
|
|
2086
3133
|
console.log(" flumecode --version # print the runner version");
|
|
2087
3134
|
process.exit(command ? 1 : 0);
|
|
2088
3135
|
}
|